Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion src/dso_api/dynamic_api/routers.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@
DatasetMVTView,
DatasetTileJSONView,
DatasetWFSView,
OGCRecordsCollectionDetailView,
OGCRecordsCollectionView,
viewset_factory,
)

Expand Down Expand Up @@ -116,6 +118,7 @@ def __init__(self):
self._index_urls = []
self._mvt_urls = []
self._wfs_urls = []
self._ogc_urls = []

def get_api_root_view(self, api_urls=None):
"""Show the OpenAPI specification as root view."""
Expand Down Expand Up @@ -165,6 +168,7 @@ def get_urls(self):
self._index_urls,
self._mvt_urls,
self._wfs_urls,
self._ogc_urls,
)
)

Expand Down Expand Up @@ -204,9 +208,10 @@ def _initialize_viewsets(self) -> list[type[DynamicModel]]:
# Sub Index views for sub paths of datasets
index_urls = self._build_index_views(api_datasets)

# mvt and wfs views
# mvt, wfs and ogc views
mvt_urls = self._build_mvt_views(api_datasets)
wfs_urls = self._build_wfs_views(api_datasets)
ogc_urls = self._build_ogc_views(api_datasets)

# Atomically copy the new viewset registrations
self.registry = self.static_routes + dataset_routes
Expand All @@ -215,6 +220,7 @@ def _initialize_viewsets(self) -> list[type[DynamicModel]]:
self._index_urls = index_urls
self._mvt_urls = mvt_urls
self._wfs_urls = wfs_urls
self._ogc_urls = ogc_urls

# invalidate the urls cache
if hasattr(self, "_urls"):
Expand Down Expand Up @@ -552,6 +558,31 @@ def _build_mvt_views(self, datasets: Iterable[Dataset]) -> list[URLPattern]:

return results

def _build_ogc_views(self, datasets: Iterable[Dataset]) -> list[URLPattern]:
"""Build the ogc views per dataset"""
results = [
path(
"/ogc/records/collections",
OGCRecordsCollectionView.as_view(),
name="ogc-records-list",
)
]

for dataset in datasets:
dataset_id = dataset.schema.id # not dataset.name which is mangled.
results.append(
path(
f"/ogc/records/collections/{dataset.path}",
OGCRecordsCollectionDetailView.as_view(),
name="ogc-records-detail",
kwargs={
"dataset_name": dataset_id,
},
)
)

return results

def _build_wfs_views(self, datasets: Iterable[Dataset]) -> list[URLPattern]:
"""Build the wfs views per dataset"""
results = []
Expand Down Expand Up @@ -679,6 +710,7 @@ def _delete_created_objects(self):
self._openapi_urls.clear()
self._mvt_urls.clear()
self._wfs_urls.clear()
self._ogc_urls.clear()
self._index_urls.clear()
if hasattr(self, "_urls"):
del self._urls
Expand Down
2 changes: 2 additions & 0 deletions src/dso_api/dynamic_api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ def get_patterns(router_urls):
path("/docs/searchindex.json", search_index),
re_path(r"/mvt/?$", views.DatasetMVTIndexView.as_view(), name="mvt-index"),
re_path(r"/wfs/?$", views.DatasetWFSIndexView.as_view(), name="wfs-index"),
re_path(r"/ogc/?$", views.OGCIndexView.as_view(), name="ogc-index"),
re_path(r"/ogc/records/?$", views.OGCRecordsIndexView.as_view(), name="ogc-records"),
path("", include(router_urls), name="api-root"),
# Swagger, OpenAPI and OAuth2 login logic.
path("/oauth2-redirect.html", views.oauth2_redirect, name="oauth2-redirect"),
Expand Down
10 changes: 10 additions & 0 deletions src/dso_api/dynamic_api/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@
from .index import APIIndexView
from .mvt import DatasetMVTIndexView, DatasetMVTSingleView, DatasetMVTView, DatasetTileJSONView
from .oauth import oauth2_redirect
from .ogc import (
OGCIndexView,
OGCRecordsCollectionDetailView,
OGCRecordsCollectionView,
OGCRecordsIndexView,
)
from .wfs import DatasetWFSIndexView, DatasetWFSView

__all__ = (
Expand All @@ -17,6 +23,10 @@
"DatasetTileJSONView",
"DatasetWFSView",
"DatasetWFSIndexView",
"OGCIndexView",
"OGCRecordsIndexView",
"OGCRecordsCollectionView",
"OGCRecordsCollectionDetailView",
"DocsIndexView",
"oauth2_redirect",
"viewset_factory",
Expand Down
8 changes: 8 additions & 0 deletions src/dso_api/dynamic_api/views/doc.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,14 @@ def get_context_data(self, **kwargs):
if ds.has_geometry_fields
else None
),
"ogc_url": (
reverse(
"dynamic_api:ogc-records-detail",
kwargs={"dataset_name": ds_schema.id},
)
if ds.has_geometry_fields
else None
),
"oauth_client_id_entra": settings.OAUTH_CLIENT_ID_ENTRA,
"oauth_authority_entra": settings.OAUTH_AUTHORITY_ENTRA,
}
Expand Down
160 changes: 160 additions & 0 deletions src/dso_api/dynamic_api/views/ogc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import logging

from django.views.generic import TemplateView
from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response
from rest_framework.views import APIView

from dso_api.dynamic_api.datasets import get_active_datasets

logger = logging.getLogger(__name__)


class OGCIndexView(TemplateView):
"""Overview of available OGC endpoints."""

template_name = "dso_api/dynamic_api/ogc_index.html"
name = "OGC endpoints"
api_type = "OGC"

def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)

base = self.request.build_absolute_uri("/").rstrip("/")
context["records_url"] = f"{base}/v1/ogc/records"

return context


class OGCRecordsIndexView(TemplateView):
"""Overview of OGC Records endpoints."""

template_name = "dso_api/dynamic_api/ogc_records.html"
name = "OGC records"
api_type = "OGC"

def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)

base = self.request.build_absolute_uri("/").rstrip("/")
context["collection_url"] = f"{base}/v1/ogc/records/collections"

return context


class OGCRecordsCollectionView(APIView):
"""Shows a JSON page with an overview of record collections."""

renderer_classes = [JSONRenderer]
name = "Record Collections"

def get_datasets(self):
return [
ds
for ds in get_active_datasets().db_enabled().order_by("name")
if ds.has_geometry_fields
]

def get(self, request, *args, **kwargs):
base = request.build_absolute_uri("/").rstrip("/")
datasets = self.get_datasets()

collections = []

for dataset in datasets:
collections.append(
{
"id": str(dataset.schema.id),
"title": dataset.schema.title or dataset.name,
"description": dataset.schema.description or "",
"links": [
{
"rel": "self",
"type": "application/json",
"href": (f"{base}/v1/ogc/records/collections/{dataset.path}"),
}
],
}
)

return Response({"collections": collections})


class OGCRecordsCollectionDetailView(APIView):
"""An JSON page for a single OGC record collection (dataset)."""

renderer_classes = [JSONRenderer]
name = "Records"

def get_datasets(self):
return [
ds
for ds in get_active_datasets().db_enabled().order_by("name")
if ds.has_geometry_fields
]
Comment thread
lotte-amsterdam marked this conversation as resolved.
Resolved

def get(self, request, *args, **kwargs):
base = request.build_absolute_uri("/").rstrip("/")
datasets = self.get_datasets()
dataset_name = self.kwargs.get("dataset_name")
collection_url = f"{base}/v1/ogc/records/collections/{dataset_name}"
records = []

ds = next(
(dataset for dataset in datasets if str(dataset.schema.id) == str(dataset_name)),
None,
)

for version, _ in ds.schema.versions.items():
records.extend(
self.build_record_collection(
ds=ds,
version=version,
collection_url=collection_url,
)
)

return Response({"records": records})

def build_record_collection(self, ds, version, collection_url):
"""Handle missing attributes and ensure each OGC field has the expected datatype."""
metadata = {
"id": f"{ds.schema.id}_{version}",
"conformsTo": [],
"type": "Collection",
"itemType": "catalog",
"title": ds.schema.title or ds.schema.name,
"description": ds.schema.description or "",
"language": {"code": "nl", "name": "Dutch (Netherlands)"},
"rights": list(ds.schema.auth),
"recordsArrayName": "records",
"links": [
{
"rel": "self",
"type": "application/json",
"href": collection_url,
},
],
}

if ds.schema.get("dateCreated"):
metadata["created"] = ds.schema.get("dateCreated")

if ds.schema.get("contactPoint"):
metadata["contacts"] = list(ds.schema.contactPoint)
else:
metadata["contacts"] = [{"name": "Datapunt", "email": "datapunt@amsterdam.nl"}]

if ds.schema.get("crs"):
metadata["crs"] = [ds.schema.get("crs")]

if ds.schema.get("theme"):
metadata["themes"] = [{"concepts": ds.schema.get("theme")}]

if ds.schema.get("keywords"):
metadata["keywords"] = list(ds.schema.get("keywords"))

if ds.schema.get("license"):
metadata["license"] = ds.schema.license

return [metadata]
1 change: 1 addition & 0 deletions src/templates/dso_api/dynamic_api/docs/dataset.html
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ <h2>Endpoints</h2>
<dt class="col-xs-2">REST OpenAPI:</dt><dd><a href="{{ swagger_url }}">{{ swagger_url }}</a></dd>
{% if wfs_url %}<dt class="col-xs-2">WFS kaartlaag:</dt><dd><a href="{{ wfs_url }}">{{ wfs_url }}</a></dd>{% endif %}
{% if mvt_url %}<dt class="col-xs-2">MVT kaartlaag:</dt><dd><a href="{{ mvt_url }}">{{ mvt_url }}</a></dd>{% endif %}
{% if ogc_url %}<dt class="col-xs-2">OGC records:</dt><dd><a href="{{ ogc_url }}">{{ ogc_url }}</a></dd>{% endif %}
</dl>

<h2>Tabellen</h2>
Expand Down
2 changes: 2 additions & 0 deletions src/templates/dso_api/dynamic_api/docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ <h1>Amsterdam DataPunt API Documentatie</h1>
<ul>
<li><a href="generic/rest/index.html">REST-API's</a> voor backend applicaties, JavaScript en mobiele-appontwikkelaars.</li>
<li><a href="generic/gis/index.html">WFS/MVT kaartlagen</a> voor GIS-professionals.</li>
<li>OGC records.</li>
<li>CSV exports.</li>
<li>GeoJSON exports.</li>
<li>Bulk exports in ZIP-formaat per dataset.</li>
Expand Down Expand Up @@ -83,6 +84,7 @@ <h3 href="{{ ds.docs_url }}" title="{{ ds.id }}">{{ ds.title|title }}</h3>
{% if ds.has_geometry_fields %}
<a href="{% url 'dynamic_api:wfs' dataset_name=ds.id %}">WFS</a>,
<a href="{% url 'dynamic_api:mvt' dataset_name=ds.id %}">MVT</a>,
<a href="{% url 'dynamic_api:ogc-records-detail' dataset_name=ds.id %}">OGC</a>,
{% endif %}
<a href="{{ ds.docs_url }}">Documentatie</a>
</dd>
Expand Down
30 changes: 30 additions & 0 deletions src/templates/dso_api/dynamic_api/ogc_index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{% extends "dso_api/dynamic_api/docs/base.html" %}{% load dso_api_tags %}


{% block breadcrumb %}
<li><a href="/v1/ogc">OGC endpoints</a></li>
{% endblock %}


{% block content %}
<blockquote style="border-color: #ec0101; margin-top: 1rem;">
<p>
Het Open Geospatial Consortium (OGC) is een internationale organisatie die open standaarden ontwikkelt
voor het uitwisselen, publiceren en opvragen van geografische informatie. Deze standaarden zorgen ervoor
dat verschillende systemen en applicaties op een uniforme manier met ruimtelijke gegevens kunnen werken.
De OGC API-standaarden maken het mogelijk om geografische gegevens en bijbehorende metadata via web-API's
beschikbaar te stellen en te gebruiken.
</p>

<p>
De Records API wordt gebruikt om gegevensbronnen te ontdekken met metadata uit het Amsterdam Schema.
</p>
</blockquote>
<main>
<h2>OGC Endpoints</h2>
<dl class="row">
{% if records_url %}<dt class="col-xs-2">Records</dt><dd><a href="{{ records_url }}">{{ records_url }}</a></dd>{% endif %}
</dl>

</main>
{% endblock %}
27 changes: 27 additions & 0 deletions src/templates/dso_api/dynamic_api/ogc_records.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@

{% extends "dso_api/dynamic_api/docs/base.html" %}{% load i18n static dso_api_tags %}

{% block breadcrumb %}
<li><a href="/v1/ogc/records">OGC Records</a></li>
{% endblock %}

{% block content %}
<blockquote style="border-color: #ec0101; margin-top: 1rem;">
<p>
OGC Records worden gebruikt om datasets en de bijbehorende datasetversies volgens de OGC specificatie
op een gestandaardiseerde manier beschikbaar te stellen. Iedere DSO-dataset wordt ontsloten als een
record collection, met metadata over deze dataset uit het Amsterdam Schema en de beschikbare datasetversies.
Iedere datasetversie wordt vervolgens beschreven door een afzonderlijk OGC-record met de bijbehorende
metadata en links naar de relevante WFS- en WMS-services. Hierdoor kunnen kaartviewers zoals NL3D en
Geosquare deze datasets en datasetversies op een uniforme manier ontdekken en de bijbehorende geografische
gegevens raadplegen.
</p>
</blockquote>

<h2>Record collections</h2>
<p>
Een record collection van een dataset bevat metadata zoals een identificatie, titel, beschrijving en links
naar de bijbehorende records (datasetversies). Voor de OGC Records API documentatie, zie <a href='https://docs.ogc.org/is/20-004r1/20-004r1.html'>OGC API - Records.</a>
Voor een overzicht van bestaande record collections met metadata uit het schema, zie <a href="{{ collection_url }}">Record Collections.</a>
</p>
{% endblock %}
2 changes: 1 addition & 1 deletion src/tests/test_dynamic_api/test_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ def test_reload_delete(router, bommen_dataset):
bommen_dataset.delete()
router.reload()
router_urls = [p.name for p in router.urls]
assert router_urls == ["api-root"]
assert router_urls == ["api-root", "ogc-records-list"]

with pytest.raises(NoReverseMatch):
assert reverse("dynamic_api:bommen-bommen-list")
Expand Down
Loading
Loading