diff --git a/src/openforms/config/admin.py b/src/openforms/config/admin.py index 34f910f9f2..b6017f8f05 100644 --- a/src/openforms/config/admin.py +++ b/src/openforms/config/admin.py @@ -14,11 +14,12 @@ CSPSetting, GlobalConfiguration, MapTileLayer, + MapWFSTileLayer, MapWMSTileLayer, RichTextColor, Theme, ) -from .resources import MapWMSTileLayerResource +from .resources import MapWFSTileLayerResource, MapWMSTileLayerResource @admin.register(GlobalConfiguration) @@ -264,6 +265,15 @@ class MapWMSTileLayerAdmin(ImportExportModelAdmin): resource_classes = (MapWMSTileLayerResource,) +@admin.register(MapWFSTileLayer) +class MapWFSTileLayerAdmin(ImportExportModelAdmin): + list_display = ("name", "url", "uuid") + search_fields = ("name", "uuid") + + # Import and export options: + resource_classes = (MapWFSTileLayerResource,) + + @admin.register(CSPSetting) class CSPSettingAdmin(admin.ModelAdmin): readonly_fields = ("content_type_link",) diff --git a/src/openforms/config/migrations/0064_mapwfstilelayer.py b/src/openforms/config/migrations/0064_mapwfstilelayer.py new file mode 100644 index 0000000000..e4be8889d1 --- /dev/null +++ b/src/openforms/config/migrations/0064_mapwfstilelayer.py @@ -0,0 +1,67 @@ +# Generated by Django 4.2.24 on 2025-10-02 10:19 + +import uuid + +from django.db import migrations, models + + +def add_default_wfs_tile_layers(apps, schema_editor): + from django.core.management import call_command + + call_command("loaddata", "default_map_wfs_tile_layers") + + +class Migration(migrations.Migration): + dependencies = [ + ("config", "0063_alter_globalconfiguration_form_map_default_latitude_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="MapWFSTileLayer", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "uuid", + models.UUIDField( + default=uuid.uuid4, + editable=False, + unique=True, + verbose_name="UUID", + ), + ), + ( + "name", + models.CharField( + help_text="An easily recognizable name for the WFS tile layer, used to identify it.", + max_length=100, + verbose_name="name", + ), + ), + ( + "url", + models.URLField( + help_text="URL to collect the WFS tile layer capabilities. To ensure correct functionality of the map, the WFS tile layer `getFeature` request should support EPSG 4326 projection and 'application/json' as output format.Example value: https://service.pdok.nl/lv/bag/wfs/v2_0?request=getCapabilities&service=WFS", + max_length=255, + verbose_name="tile layer url", + ), + ), + ], + options={ + "verbose_name": "WFS layer", + "verbose_name_plural": "WFS layers", + }, + ), + migrations.RunPython( + code=add_default_wfs_tile_layers, + reverse_code=migrations.RunPython.noop, + ), + ] diff --git a/src/openforms/config/models/__init__.py b/src/openforms/config/models/__init__.py index 231d431459..5c1baf8847 100644 --- a/src/openforms/config/models/__init__.py +++ b/src/openforms/config/models/__init__.py @@ -1,7 +1,7 @@ from .color import RichTextColor from .config import GlobalConfiguration from .csp import CSPSetting -from .map import MapTileLayer, MapWMSTileLayer +from .map import MapTileLayer, MapWFSTileLayer, MapWMSTileLayer from .theme import Theme __all__ = [ @@ -9,6 +9,7 @@ "GlobalConfiguration", "RichTextColor", "MapTileLayer", + "MapWFSTileLayer", "MapWMSTileLayer", "Theme", ] diff --git a/src/openforms/config/models/map.py b/src/openforms/config/models/map.py index d455056b01..83839d9995 100644 --- a/src/openforms/config/models/map.py +++ b/src/openforms/config/models/map.py @@ -96,3 +96,46 @@ def __str__(self): def natural_key(self): return (self.uuid,) + + +class MapWFSTileLayerManager(models.Manager["MapWFSTileLayer"]): + def get_by_natural_key(self, uuid: str) -> MapWFSTileLayer: + return self.get(uuid=uuid) + + +class MapWFSTileLayer(models.Model): + uuid = models.UUIDField( + _("UUID"), + unique=True, + default=_uuid.uuid4, + editable=False, + ) + name = models.CharField( + _("name"), + max_length=100, + help_text=_( + "An easily recognizable name for the WFS tile layer, used to identify it." + ), + ) + url = models.URLField( + _("tile layer url"), + max_length=255, + help_text=_( + "URL to collect the WFS tile layer capabilities. To ensure correct " + "functionality of the map, the WFS tile layer `getFeature` request should " + "support EPSG 4326 projection and 'application/json' as output format." + "Example value: https://service.pdok.nl/lv/bag/wfs/v2_0?request=getCapabilities&service=WFS" + ), + ) + + objects = MapWFSTileLayerManager() + + class Meta: + verbose_name = _("WFS layer") + verbose_name_plural = _("WFS layers") + + def __str__(self): + return self.name + + def natural_key(self): + return (self.uuid,) diff --git a/src/openforms/config/resources.py b/src/openforms/config/resources.py index cbb199355e..1eb7c606a3 100644 --- a/src/openforms/config/resources.py +++ b/src/openforms/config/resources.py @@ -1,6 +1,6 @@ from import_export import resources -from .models import MapWMSTileLayer +from .models import MapWFSTileLayer, MapWMSTileLayer class MapWMSTileLayerResource(resources.ModelResource): @@ -9,3 +9,11 @@ class Meta: # Use uuid as identifier import_id_fields = ("uuid",) fields = ("uuid", "name", "url") + + +class MapWFSTileLayerResource(resources.ModelResource): + class Meta: + model = MapWFSTileLayer + # Use uuid as identifier + import_id_fields = ("uuid",) + fields = ("uuid", "name", "url") diff --git a/src/openforms/config/tests/factories.py b/src/openforms/config/tests/factories.py index e1b8fc6c77..63ed3f05f8 100644 --- a/src/openforms/config/tests/factories.py +++ b/src/openforms/config/tests/factories.py @@ -31,3 +31,11 @@ class MapWMSTileLayerFactory(factory.django.DjangoModelFactory): class Meta: model = "config.MapWMSTileLayer" + + +class MapWFSTileLayerFactory(factory.django.DjangoModelFactory): + url = factory.Sequence(lambda n: f"http://example-{n}.com") + name = factory.Faker("word") + + class Meta: + model = "config.MapWFSTileLayer" diff --git a/src/openforms/emails/digest.py b/src/openforms/emails/digest.py index f986f0112d..d9a20cfca9 100644 --- a/src/openforms/emails/digest.py +++ b/src/openforms/emails/digest.py @@ -1,9 +1,11 @@ import uuid from collections import defaultdict -from collections.abc import Collection, Iterable, Iterator, Mapping, MutableMapping +from collections.abc import Collection, Iterable, Iterator, MutableMapping from dataclasses import dataclass from datetime import datetime, timedelta -from itertools import groupby +from io import BytesIO +from itertools import chain, groupby +from typing import assert_never from django.contrib.contenttypes.models import ContentType from django.db.models import Q @@ -17,13 +19,18 @@ from furl import furl from json_logic.typing import JSON from lxml import etree +from lxml.etree import _Element from requests.exceptions import RequestException from rest_framework import serializers from simple_certmanager.models import Certificate from zgw_consumers.client import build_client from zgw_consumers.models import Service -from openforms.config.models import GlobalConfiguration, MapWMSTileLayer +from openforms.config.models import ( + GlobalConfiguration, + MapWFSTileLayer, + MapWMSTileLayer, +) from openforms.contrib.brk.service import check_brk_config_for_addressNL from openforms.contrib.kadaster.service import check_bag_config_for_address_fields from openforms.contrib.reference_lists.client import ( @@ -33,7 +40,7 @@ ) from openforms.formio.constants import DataSrcOptions from openforms.formio.typing import Component -from openforms.formio.typing.map import Overlay +from openforms.formio.typing.map import Overlay, OverlayType from openforms.forms.constants import LogicActionTypes from openforms.forms.models import Form from openforms.forms.models.form_registration_backend import FormRegistrationBackend @@ -693,16 +700,18 @@ def collect_expired_or_near_expiry_reference_lists_data() -> list[ return problems -class WMS: +class TileLayer: """ - Helper to process WMS tile layer configuration. + Helper to process WMS and WFS tile layer configuration. """ _layers_for_wms_url: MutableMapping[str, set[str] | requests.RequestException] + _layers_for_wfs_url: MutableMapping[str, set[str] | requests.RequestException] def __init__(self): self.session = requests.Session() self._layers_for_wms_url = {} + self._layers_for_wfs_url = {} def __enter__(self): self.session.__enter__() @@ -711,28 +720,109 @@ def __enter__(self): def __exit__(self, *args): self.session.__exit__(*args) - def get_layer_names(self, wms_layer_url: str) -> Collection[str]: - if self._layers_for_wms_url.get(wms_layer_url) is None: + def _get_layer_names( + self, + root: type[_Element] | None, + namespaces: dict[str, str], + layer_type: OverlayType, + ) -> list[_Element] | None: + match layer_type: + case "wms": + return self._get_wms_layer_names(root, namespaces) + case "wfs": + return self._get_wfs_layer_names(root, namespaces) + case _: + assert_never(layer_type) + + @staticmethod + def _get_wms_layer_names( + root: type[_Element] | None, namespaces: dict[str, str] + ) -> list[_Element] | None: + if "wms" in namespaces: + # Try with common wms standard namespace first (used in WMS 1.3.0) + return root.findall( + path=".//wms:Layer/wms:Name", + namespaces=namespaces, + ) + else: + # Fallback to no namespace (for WMS 1.1.1) + return root.findall(path=".//Layer/Name") + + @staticmethod + def _get_wfs_layer_names( + root: type[_Element] | None, namespaces: dict[str, str] + ) -> list[_Element] | None: + if "wfs" in namespaces: + # Try with wfs namespace + return root.findall( + path=".//wfs:FeatureType/wfs:Name", + namespaces=namespaces, + ) + else: + # Fallback to wfs without namespaces + return root.findall(path=".//FeatureType/Name") + + def _get_layer_names_from_cache( + self, layer_url: str, layer_type: OverlayType + ) -> set[str] | None: + match layer_type: + case "wms": + return self._layers_for_wms_url.get(layer_url) + case "wfs": + return self._layers_for_wfs_url.get(layer_url) + case _: + assert_never(layer_type) + + def _set_result_into_cache( + self, + layer_url: str, + layer_type: OverlayType, + result: set[str] | requests.RequestException, + ): + match layer_type: + case "wms": + self._layers_for_wms_url[layer_url] = result + case "wfs": + self._layers_for_wfs_url[layer_url] = result + case _: + assert_never(layer_type) + + @staticmethod + def _extract_namespaces(xml_bytes: bytes, default: str) -> dict[str, str]: + """Extract all namespace prefixes and URIs from an XML document.""" + namespaces = {} + for event, elem in etree.iterparse(BytesIO(xml_bytes), events=("start-ns",)): + prefix, uri = elem + namespaces[prefix if prefix else default] = uri + + return namespaces + + def get_layer_names( + self, layer_url: str, layer_type: OverlayType + ) -> Collection[str]: + cache_result = self._get_layer_names_from_cache(layer_url, layer_type) + should_populate_cache = cache_result is None + + if should_populate_cache: # populate the cache and do the expensive processing try: - response = self.session.get(wms_layer_url) + response = self.session.get(layer_url) response.raise_for_status() except requests.RequestException as exc: - self._layers_for_wms_url[wms_layer_url] = exc + cache_result = exc else: root = etree.fromstring(response.content) - # Try with common wms standard namespace first (used in WMS 1.3.0) - names = root.findall( - ".//wms:Layer/wms:Name", - namespaces={"wms": "http://www.opengis.net/wms"}, - # Fallback to no namespace (for WMS 1.1.1) - ) or root.findall(".//Layer/Name") - - self._layers_for_wms_url[wms_layer_url] = set( + namespaces = self._extract_namespaces(response.content, layer_type) + names = self._get_layer_names(root, namespaces, layer_type) + + cache_result = set( element.text.strip() for element in names if element.text ) - cache_result = self._layers_for_wms_url[wms_layer_url] + if should_populate_cache: + # append the new result into the cache + self._set_result_into_cache(layer_url, layer_type, cache_result) + if isinstance(cache_result, requests.RequestException): raise cache_result return cache_result @@ -740,9 +830,12 @@ def get_layer_names(self, wms_layer_url: str) -> Collection[str]: def collect_invalid_map_component_overlays() -> list[InvalidMapComponentOverlay]: live_forms = Form.objects.live() - wms_tile_layers_map: Mapping[str, str] = { + tile_layers_map: dict[str, str] = { str(uuid): str(url) - for uuid, url in MapWMSTileLayer.objects.values_list("uuid", "url") + for uuid, url in chain( + MapWMSTileLayer.objects.values_list("uuid", "url"), + MapWFSTileLayer.objects.values_list("uuid", "url"), + ) } problems: list[InvalidMapComponentOverlay] = [] @@ -757,11 +850,11 @@ def _iter_overlays() -> Iterator[tuple[Form, Component, Overlay]]: for overlay in overlays: yield form, component, overlay - with WMS() as wms_helper: + with TileLayer() as tile_layer_helper: for form, component, overlay in _iter_overlays(): - overlay_url = wms_tile_layers_map.get(overlay["uuid"]) + overlay_url = tile_layers_map.get(overlay["uuid"]) - # Is the uuid connected to a known WMS tile layer + # Is the uuid connected to a known WMS or WFS tile layer if not overlay_url: problems.append( InvalidMapComponentOverlay( @@ -775,7 +868,9 @@ def _iter_overlays() -> Iterator[tuple[Form, Component, Overlay]]: continue try: - xml_layer_names = wms_helper.get_layer_names(overlay_url) + xml_layer_names = tile_layer_helper.get_layer_names( + overlay_url, overlay["type"] + ) except requests.RequestException: problems.append( InvalidMapComponentOverlay( @@ -788,8 +883,8 @@ def _iter_overlays() -> Iterator[tuple[Form, Component, Overlay]]: ) continue - # Check if all overlay layers are actually available in the WMS tile - # layer. It could be that the WMS tile layer was updated, and that the + # Check if all overlay layers are actually available in the WMS/WFS tile + # layer. It could be that the WMS/WFS tile layer was updated, and that the # layer is no-longer available. missing_layers = set(overlay["layers"]) - set(xml_layer_names) if missing_layers: diff --git a/src/openforms/emails/tests/data/vcr_cassettes/InvalidMapComponentOverlaysTests/test_invalid_map_component_with_multiple_unknown_wfs_overlays_layers.yaml b/src/openforms/emails/tests/data/vcr_cassettes/InvalidMapComponentOverlaysTests/test_invalid_map_component_with_multiple_unknown_wfs_overlays_layers.yaml new file mode 100644 index 0000000000..445677239f --- /dev/null +++ b/src/openforms/emails/tests/data/vcr_cassettes/InvalidMapComponentOverlaysTests/test_invalid_map_component_with_multiple_unknown_wfs_overlays_layers.yaml @@ -0,0 +1,154 @@ +interactions: +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate, br + Connection: + - keep-alive + User-Agent: + - python-requests/2.32.4 + method: GET + uri: https://service.pdok.nl/rws/nwbwegen/wfs/v1_0?request=GetCapabilities&service=WFS + response: + body: + string: "\n\n\t\n\t\tNWB + - Wegen WFS\n\t\tDit is de web feature service van + het Nationaal Wegen Bestand (NWB) - wegen. Deze dataset bevat alleen de wegvakken + en hectometerpunten. Het Nationaal Wegen Bestand - Wegen is een digitaal geografisch + bestand van alle wegen in Nederland. Opgenomen zijn alle wegen die worden + beheerd door wegbeheerders als het Rijk, provincies, gemeenten en waterschappen, + echter alleen voor zover deze zijn voorzien van een straatnaam of nummer.\n\t\t\n\t\t\tVervoersnetwerken\n\t\t\tMenselijke + gezondheid en veiligheid\n\t\t\tGeluidsbelasting + hoofdwegen (Richtlijn Omgevingslawaai)\n\t\t\tNationaal\n\t\t\tVoertuigen\n\t\t\tVerkeer\n\t\t\tWegvakken\n\t\t\tHectometerpunten\n\t\t\tHVD\n\t\t\tMobiliteit\n\t\t\tinfoFeatureAccessService\n\t\t\n\t\tWFS\n\t\t2.0.0\n\t\tnone\n\t\thttps://creativecommons.org/publicdomain/zero/1.0/deed.nl\n\t\n\t\n\t\tPDOK\n\t\t\n\t\t\n\t\t\tKlantContactCenter + PDOK\n\t\t\tpointOfContact\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\tApeldoorn\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\tNetherlands\n\t\t\t\t\tBeheerPDOK@kadaster.nl\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t\n\t\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t2.0.0\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\ttext/xml\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\tServiceIdentification\n\t\t\t\t\tServiceProvider\n\t\t\t\t\tOperationsMetadata\n\t\t\t\t\tFeatureTypeList\n\t\t\t\t\tFilter_Capabilities\n\t\t\t\t\n\t\t\t\n\t\t\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\tapplication/gml+xml; + version=3.2\n\t\t\t\t\ttext/xml; subtype=gml/3.2.1\n\t\t\t\t\ttext/xml; + subtype=gml/3.1.1\n\t\t\t\t\n\t\t\t\n\t\t\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\tapplication/gml+xml; + version=3.2\n\t\t\t\t\ttext/xml; subtype=gml/3.2.1\n\t\t\t\t\ttext/xml; + subtype=gml/3.1.1\n\t\t\t\t\tapplication/json; subtype=geojson\n\t\t\t\t\tapplication/json\n\t\t\t\t\n\t\t\t\n\t\t\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\n\t\t\n\t\t\t\n\t\t\t\t2.0.0\n\t\t\t\n\t\t\n\t\t\n\t\t\t\n\t\t\tTRUE\n\t\t\n\t\t\n\t\t\t\n\t\t\tFALSE\n\t\t\n\t\t\n\t\t\t\n\t\t\tFALSE\n\t\t\n\t\t\n\t\t\t\n\t\t\tTRUE\n\t\t\n\t\t\n\t\t\t\n\t\t\tTRUE\n\t\t\n\t\t\n\t\t\t\n\t\t\tFALSE\n\t\t\n\t\t\n\t\t\t\n\t\t\tFALSE\n\t\t\n\t\t\n\t\t\t\n\t\t\tFALSE\n\t\t\n\t\t\n\t\t\t\n\t\t\tTRUE\n\t\t\n\t\t\n\t\t\t\n\t\t\tFALSE\n\t\t\n\t\t\n\t\t\t\n\t\t\tFALSE\n\t\t\n\t\t\n\t\t\t\n\t\t\tFALSE\n\t\t\n\t\t\n\t\t\t\n\t\t\tFALSE\n\t\t\n\t\t\n\t\t\t\n\t\t\tFALSE\n\t\t\n\t\t\n\t\t\t\n\t\t\tFALSE\n\t\t\n\t\t\n\t\t\t\n\t\t\t1000\n\t\t\n\t\t\n\t\t\t\n\t\t\t\twfs:Query\n\t\t\t\twfs:StoredQuery\n\t\t\t\n\t\t\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\thttps://www.nationaalgeoregister.nl/geonetwork/srv/dut/csw?service=CSW&version=2.0.2&request=GetRecordById&outputschema=http://www.isotc211.org/2005/gmd&elementsetname=full&id=a9fa7fff-6365-4885-950c-e9d9848359ee\n\t\t\t\t\tapplication/vnd.ogc.csw.GetRecordByIdResponse_xml\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tdut\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\tdut\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t8f0497f0-dbd7-4bee-b85a-5fdec484a7ff\n\t\t\t\t\n\t\t\t\n\t\t\n\t\n\t\n\t\t\n\t\t\tnwbwegen:wegvakken\n\t\t\tWegvakken\n\t\t\tDit + featuretype bevat de wegvakken uit het Nationaal Wegen bestand (NWB) en bevat + gedetailleerde informatie per wegvak zoals straatnaam, wegnummer, routenummer, + wegbeheerder, huisnummers, enz.\n\t\t\t\n\t\t\t\tVervoersnetwerken\n\t\t\t\tMenselijke + gezondheid en veiligheid\n\t\t\t\tGeluidsbelasting + hoofdwegen (Richtlijn Omgevingslawaai)\n\t\t\t\tNationaal\n\t\t\t\tVoertuigen\n\t\t\t\tVerkeer\n\t\t\t\tWegvakken\n\t\t\t\n\t\t\turn:ogc:def:crs:EPSG::28992\n\t\t\turn:ogc:def:crs:EPSG::25831\n\t\t\turn:ogc:def:crs:EPSG::25832\n\t\t\turn:ogc:def:crs:EPSG::3034\n\t\t\turn:ogc:def:crs:EPSG::3035\n\t\t\turn:ogc:def:crs:EPSG::3857\n\t\t\turn:ogc:def:crs:EPSG::4258\n\t\t\turn:ogc:def:crs:EPSG::4326\n\t\t\t\n\t\t\t\tapplication/gml+xml; + version=3.2\n\t\t\t\ttext/xml; subtype=gml/3.2.1\n\t\t\t\ttext/xml; + subtype=gml/3.1.1\n\t\t\t\tapplication/json; subtype=geojson\n\t\t\t\tapplication/json\n\t\t\t\n\t\t\t\n\t\t\t\t2.527125 + 50.212863\n\t\t\t\t7.374026 55.721160\n\t\t\t\n\t\t\t\n\t\t\n\t\t\n\t\t\tnwbwegen:hectopunten\n\t\t\tHectopunten\n\t\t\tDit + featuretype bevat de hectopunten uit het Nationaal Wegen Bestand (NWB) en + bevat gedetailleerde informatie per hectopunt zoals hectometrering, afstand, + zijde en hectoletter.\n\t\t\t\n\t\t\t\tVervoersnetwerken\n\t\t\t\tMenselijke + gezondheid en veiligheid\n\t\t\t\tGeluidsbelasting + hoofdwegen (Richtlijn Omgevingslawaai)\n\t\t\t\tNationaal\n\t\t\t\tVoertuigen\n\t\t\t\tVerkeer\n\t\t\t\tHectometerpunten\n\t\t\t\n\t\t\turn:ogc:def:crs:EPSG::28992\n\t\t\turn:ogc:def:crs:EPSG::25831\n\t\t\turn:ogc:def:crs:EPSG::25832\n\t\t\turn:ogc:def:crs:EPSG::3034\n\t\t\turn:ogc:def:crs:EPSG::3035\n\t\t\turn:ogc:def:crs:EPSG::3857\n\t\t\turn:ogc:def:crs:EPSG::4258\n\t\t\turn:ogc:def:crs:EPSG::4326\n\t\t\t\n\t\t\t\tapplication/gml+xml; + version=3.2\n\t\t\t\ttext/xml; subtype=gml/3.2.1\n\t\t\t\ttext/xml; + subtype=gml/3.1.1\n\t\t\t\tapplication/json; subtype=geojson\n\t\t\t\tapplication/json\n\t\t\t\n\t\t\t\n\t\t\t\t2.527125 + 50.212863\n\t\t\t\t7.374026 55.721160\n\t\t\t\n\t\t\t\n\t\t\n\t\n\t\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\tTRUE\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\tTRUE\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\tFALSE\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\tTRUE\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\tTRUE\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\tTRUE\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\tTRUE\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\tFALSE\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\tTRUE\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\tFALSE\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\tFALSE\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\tTRUE\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\tFALSE\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\tTRUE\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\tFALSE\n\t\t\t\n\t\t\n\t\t\n\t\t\t\n\t\t\n\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\n\t\n" + headers: + Accept-Ranges: + - bytes + Access-Control-Allow-Headers: + - Content-Type + Access-Control-Allow-Method: + - GET, POST, OPTIONS + Access-Control-Allow-Origin: + - '*' + Cache-Control: + - public, max-age=3600, no-transform + Content-Length: + - '17253' + Content-Type: + - text/xml; charset=UTF-8 + Date: + - Thu, 02 Oct 2025 15:20:40 GMT + Referrer-Policy: + - origin + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + X-Content-Type-Options: + - nosniff + status: + code: 200 + message: OK +version: 1 diff --git a/src/openforms/emails/tests/vcr_cassettes/test_digest_functions/InvalidMapComponentOverlaysTests/test_invalid_map_component_with_multiple_unknown_overlays_layers.yaml b/src/openforms/emails/tests/data/vcr_cassettes/InvalidMapComponentOverlaysTests/test_invalid_map_component_with_multiple_unknown_wms_overlays_layers.yaml similarity index 99% rename from src/openforms/emails/tests/vcr_cassettes/test_digest_functions/InvalidMapComponentOverlaysTests/test_invalid_map_component_with_multiple_unknown_overlays_layers.yaml rename to src/openforms/emails/tests/data/vcr_cassettes/InvalidMapComponentOverlaysTests/test_invalid_map_component_with_multiple_unknown_wms_overlays_layers.yaml index 8f6eef4207..f77bdf499b 100644 --- a/src/openforms/emails/tests/vcr_cassettes/test_digest_functions/InvalidMapComponentOverlaysTests/test_invalid_map_component_with_multiple_unknown_overlays_layers.yaml +++ b/src/openforms/emails/tests/data/vcr_cassettes/InvalidMapComponentOverlaysTests/test_invalid_map_component_with_multiple_unknown_wms_overlays_layers.yaml @@ -239,7 +239,7 @@ interactions: Content-Type: - text/xml; charset=UTF-8 Date: - - Tue, 16 Sep 2025 09:45:20 GMT + - Thu, 02 Oct 2025 15:20:41 GMT Referrer-Policy: - origin Strict-Transport-Security: diff --git a/src/openforms/emails/tests/data/vcr_cassettes/InvalidMapComponentOverlaysTests/test_valid_map_component_with_wfs_overlays.yaml b/src/openforms/emails/tests/data/vcr_cassettes/InvalidMapComponentOverlaysTests/test_valid_map_component_with_wfs_overlays.yaml new file mode 100644 index 0000000000..e62e042171 --- /dev/null +++ b/src/openforms/emails/tests/data/vcr_cassettes/InvalidMapComponentOverlaysTests/test_valid_map_component_with_wfs_overlays.yaml @@ -0,0 +1,388 @@ +interactions: +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate, br + Connection: + - keep-alive + User-Agent: + - python-requests/2.32.4 + method: GET + uri: https://service.pdok.nl/rws/nwbwegen/wfs/v1_0?request=GetCapabilities&service=WFS + response: + body: + string: "\n\n\t\n\t\tNWB + - Wegen WFS\n\t\tDit is de web feature service van + het Nationaal Wegen Bestand (NWB) - wegen. Deze dataset bevat alleen de wegvakken + en hectometerpunten. Het Nationaal Wegen Bestand - Wegen is een digitaal geografisch + bestand van alle wegen in Nederland. Opgenomen zijn alle wegen die worden + beheerd door wegbeheerders als het Rijk, provincies, gemeenten en waterschappen, + echter alleen voor zover deze zijn voorzien van een straatnaam of nummer.\n\t\t\n\t\t\tVervoersnetwerken\n\t\t\tMenselijke + gezondheid en veiligheid\n\t\t\tGeluidsbelasting + hoofdwegen (Richtlijn Omgevingslawaai)\n\t\t\tNationaal\n\t\t\tVoertuigen\n\t\t\tVerkeer\n\t\t\tWegvakken\n\t\t\tHectometerpunten\n\t\t\tHVD\n\t\t\tMobiliteit\n\t\t\tinfoFeatureAccessService\n\t\t\n\t\tWFS\n\t\t2.0.0\n\t\tnone\n\t\thttps://creativecommons.org/publicdomain/zero/1.0/deed.nl\n\t\n\t\n\t\tPDOK\n\t\t\n\t\t\n\t\t\tKlantContactCenter + PDOK\n\t\t\tpointOfContact\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\tApeldoorn\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\tNetherlands\n\t\t\t\t\tBeheerPDOK@kadaster.nl\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t\n\t\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t2.0.0\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\ttext/xml\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\tServiceIdentification\n\t\t\t\t\tServiceProvider\n\t\t\t\t\tOperationsMetadata\n\t\t\t\t\tFeatureTypeList\n\t\t\t\t\tFilter_Capabilities\n\t\t\t\t\n\t\t\t\n\t\t\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\tapplication/gml+xml; + version=3.2\n\t\t\t\t\ttext/xml; subtype=gml/3.2.1\n\t\t\t\t\ttext/xml; + subtype=gml/3.1.1\n\t\t\t\t\n\t\t\t\n\t\t\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\tapplication/gml+xml; + version=3.2\n\t\t\t\t\ttext/xml; subtype=gml/3.2.1\n\t\t\t\t\ttext/xml; + subtype=gml/3.1.1\n\t\t\t\t\tapplication/json; subtype=geojson\n\t\t\t\t\tapplication/json\n\t\t\t\t\n\t\t\t\n\t\t\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\n\t\t\n\t\t\t\n\t\t\t\t2.0.0\n\t\t\t\n\t\t\n\t\t\n\t\t\t\n\t\t\tTRUE\n\t\t\n\t\t\n\t\t\t\n\t\t\tFALSE\n\t\t\n\t\t\n\t\t\t\n\t\t\tFALSE\n\t\t\n\t\t\n\t\t\t\n\t\t\tTRUE\n\t\t\n\t\t\n\t\t\t\n\t\t\tTRUE\n\t\t\n\t\t\n\t\t\t\n\t\t\tFALSE\n\t\t\n\t\t\n\t\t\t\n\t\t\tFALSE\n\t\t\n\t\t\n\t\t\t\n\t\t\tFALSE\n\t\t\n\t\t\n\t\t\t\n\t\t\tTRUE\n\t\t\n\t\t\n\t\t\t\n\t\t\tFALSE\n\t\t\n\t\t\n\t\t\t\n\t\t\tFALSE\n\t\t\n\t\t\n\t\t\t\n\t\t\tFALSE\n\t\t\n\t\t\n\t\t\t\n\t\t\tFALSE\n\t\t\n\t\t\n\t\t\t\n\t\t\tFALSE\n\t\t\n\t\t\n\t\t\t\n\t\t\tFALSE\n\t\t\n\t\t\n\t\t\t\n\t\t\t1000\n\t\t\n\t\t\n\t\t\t\n\t\t\t\twfs:Query\n\t\t\t\twfs:StoredQuery\n\t\t\t\n\t\t\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\thttps://www.nationaalgeoregister.nl/geonetwork/srv/dut/csw?service=CSW&version=2.0.2&request=GetRecordById&outputschema=http://www.isotc211.org/2005/gmd&elementsetname=full&id=a9fa7fff-6365-4885-950c-e9d9848359ee\n\t\t\t\t\tapplication/vnd.ogc.csw.GetRecordByIdResponse_xml\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tdut\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\tdut\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t8f0497f0-dbd7-4bee-b85a-5fdec484a7ff\n\t\t\t\t\n\t\t\t\n\t\t\n\t\n\t\n\t\t\n\t\t\tnwbwegen:wegvakken\n\t\t\tWegvakken\n\t\t\tDit + featuretype bevat de wegvakken uit het Nationaal Wegen bestand (NWB) en bevat + gedetailleerde informatie per wegvak zoals straatnaam, wegnummer, routenummer, + wegbeheerder, huisnummers, enz.\n\t\t\t\n\t\t\t\tVervoersnetwerken\n\t\t\t\tMenselijke + gezondheid en veiligheid\n\t\t\t\tGeluidsbelasting + hoofdwegen (Richtlijn Omgevingslawaai)\n\t\t\t\tNationaal\n\t\t\t\tVoertuigen\n\t\t\t\tVerkeer\n\t\t\t\tWegvakken\n\t\t\t\n\t\t\turn:ogc:def:crs:EPSG::28992\n\t\t\turn:ogc:def:crs:EPSG::25831\n\t\t\turn:ogc:def:crs:EPSG::25832\n\t\t\turn:ogc:def:crs:EPSG::3034\n\t\t\turn:ogc:def:crs:EPSG::3035\n\t\t\turn:ogc:def:crs:EPSG::3857\n\t\t\turn:ogc:def:crs:EPSG::4258\n\t\t\turn:ogc:def:crs:EPSG::4326\n\t\t\t\n\t\t\t\tapplication/gml+xml; + version=3.2\n\t\t\t\ttext/xml; subtype=gml/3.2.1\n\t\t\t\ttext/xml; + subtype=gml/3.1.1\n\t\t\t\tapplication/json; subtype=geojson\n\t\t\t\tapplication/json\n\t\t\t\n\t\t\t\n\t\t\t\t2.527125 + 50.212863\n\t\t\t\t7.374026 55.721160\n\t\t\t\n\t\t\t\n\t\t\n\t\t\n\t\t\tnwbwegen:hectopunten\n\t\t\tHectopunten\n\t\t\tDit + featuretype bevat de hectopunten uit het Nationaal Wegen Bestand (NWB) en + bevat gedetailleerde informatie per hectopunt zoals hectometrering, afstand, + zijde en hectoletter.\n\t\t\t\n\t\t\t\tVervoersnetwerken\n\t\t\t\tMenselijke + gezondheid en veiligheid\n\t\t\t\tGeluidsbelasting + hoofdwegen (Richtlijn Omgevingslawaai)\n\t\t\t\tNationaal\n\t\t\t\tVoertuigen\n\t\t\t\tVerkeer\n\t\t\t\tHectometerpunten\n\t\t\t\n\t\t\turn:ogc:def:crs:EPSG::28992\n\t\t\turn:ogc:def:crs:EPSG::25831\n\t\t\turn:ogc:def:crs:EPSG::25832\n\t\t\turn:ogc:def:crs:EPSG::3034\n\t\t\turn:ogc:def:crs:EPSG::3035\n\t\t\turn:ogc:def:crs:EPSG::3857\n\t\t\turn:ogc:def:crs:EPSG::4258\n\t\t\turn:ogc:def:crs:EPSG::4326\n\t\t\t\n\t\t\t\tapplication/gml+xml; + version=3.2\n\t\t\t\ttext/xml; subtype=gml/3.2.1\n\t\t\t\ttext/xml; + subtype=gml/3.1.1\n\t\t\t\tapplication/json; subtype=geojson\n\t\t\t\tapplication/json\n\t\t\t\n\t\t\t\n\t\t\t\t2.527125 + 50.212863\n\t\t\t\t7.374026 55.721160\n\t\t\t\n\t\t\t\n\t\t\n\t\n\t\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\tTRUE\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\tTRUE\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\tFALSE\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\tTRUE\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\tTRUE\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\tTRUE\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\tTRUE\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\tFALSE\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\tTRUE\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\tFALSE\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\tFALSE\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\tTRUE\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\tFALSE\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\tTRUE\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\tFALSE\n\t\t\t\n\t\t\n\t\t\n\t\t\t\n\t\t\n\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\n\t\n" + headers: + Accept-Ranges: + - bytes + Access-Control-Allow-Headers: + - Content-Type + Access-Control-Allow-Method: + - GET, POST, OPTIONS + Access-Control-Allow-Origin: + - '*' + Cache-Control: + - public, max-age=3600, no-transform + Content-Length: + - '17253' + Content-Type: + - text/xml; charset=UTF-8 + Date: + - Thu, 02 Oct 2025 15:20:41 GMT + Referrer-Policy: + - origin + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + X-Content-Type-Options: + - nosniff + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate, br + Connection: + - keep-alive + User-Agent: + - python-requests/2.32.4 + method: GET + uri: https://schemas.opengis.net/wfs/1.1.0/examples/WFS_Capabilities_Sample.xml + response: + body: + string: "\n\n\n \n \n + \ \n \n OGC Member WFS\n + \ \n Web Feature Service maintained by NSDI data + provider, serving\n FGDC framework layer XXX; contact Paul.Bunyon@BlueOx.org\n + \ \n \n FGDC\n + \ NSDI\n Framework + Data Layer\n BlueOx\n String\n + \ \n WFS\n 1.1.0\n + \ None\n None\n + \ \n\n \n \n + \ \n \n BlueOx\n + \ \n \n + \ Paul Bunyon\n Mythology + Manager\n \n \n + \ 1.800.BIG.WOOD\n 1.800.FAX.WOOD\n + \ \n \n North + Country\n Small Town\n + \ Rural County\n + \ 12345\n USA\n + \ Paul.Bunyon@BlueOx.org\n + \ \n \n + \ 24x7\n \n + \ eMail Paul with normal requsts; Phone Paul for emergency\n + \ requests; if you get voice mail and your request can't wait,\n + \ contact another mythological figure listed on the contactUs\n + \ page of our web site.\n \n + \ \n PointOfContact\n + \ \n \n\n \n \n + \ \n \n \n + \ \n \n \n + \ \n \n \n + \ 1.1.0\n 1.0.0\n + \ \n \n + \ text/xml\n \n \n ServiceIdentification\n + \ ServiceProvider\n OperationsMetadata\n + \ FeatureTypeList\n ServesGMLObjectTypeList\n + \ SupportsGMLObjectTypeList\n Filter_Capabilities\n + \ \n \n \n + \ \n \n \n + \ \n + \ \n \n \n + \ text/xml; subtype=gml/3.1.1\n \n + \ \n \n \n + \ \n \n + \ \n + \ \n \n \n + \ results\n hits\n + \ \n \n + \ text/xml; subtype=gml/3.1.1\n \n + \ \n \n + \ \n \n \n + \ \n \n \n + \ results\n hits\n + \ \n \n + \ text/xml; subtype=gml/3.1.1\n \n + \ \n \n \n + \ \n \n + \ \n \n \n + \ text/xml; subtype=gml/3.1.1\n text/xhtml\n + \ \n \n + \ 0\n *\n + \ \n \n + \ 0\n *\n + \ \n \n \n + \ \n \n \n + \ \n \n \n + \ ALL\n SOME\n + \ \n \n \n + \ \n \n \n + \ \n \n \n + \ text/xml; subtype=gml/3.1.1\n \n + \ \n GenerateNew\n + \ UseExisting\n ReplaceDuplicate\n + \ \n \n + \ ALL\n SOME\n + \ \n \n \n + \ EPSG:4326\n EPSG:32100\n + \ EPSG:32101\n EPSG:32102\n + \ \n \n + \ 10000\n \n \n 0\n *\n + \ \n \n + \ 0\n *\n \n + \ \n 5\n + \ \n \n\n \n \n + \ \n \n \n + \ bo:WoodsType\n The Great + Northern Forest\n \n Describes + the arborial diversity of the Great\n Northern Forest.\n \n + \ \n forest\n north\n + \ woods\n arborial\n + \ diversity\n \n + \ EPSG:62696405\n EPSG:32615\n + \ EPSG:32616\n EPSG:32617\n + \ EPSG:32618\n \n + \ text/xml; subtype=gml/3.1.1\n \n + \ \n -180 -90\n + \ 180 90\n \n + \ http://www.ogccatservice.com/csw.cgi?service=CSW&version=2.0.0&request=GetRecords&constraintlanguage=CQL&constraint=\"recordid=urn:uuid:4ee8b2d3-9409-4a1d-b26b-6782e4fa3d59\"\n + \ \n \n\n \n \n + \ \n \n \n + \ bo:OxType\n Babe's Lineage\n + \ \n text/xml; subtype=gml/3.1.1\n + \ text/xhmtl\n \n + \ \n \n\n \n \n + \ \n \n \n gml:AbstractGMLFeatureType\n + \ \n text/xml; subtype=gml/3.1.1\n + \ text/xhtml\n \n + \ \n \n gml:PointType\n + \ \n text/xml; subtype=gml/3.1.1\n + \ text/xhtml\n \n + \ \n \n gml:LineStringType\n + \ \n text/xml; subtype=gml/3.1.1\n + \ text/xhtml\n \n + \ \n \n gml:PolygonType\n + \ \n text/xml; subtype=gml/3.1.1\n + \ text/xhtml\n \n + \ \n \n gml:MultiPointType\n + \ \n text/xml; subtype=gml/3.1.1\n + \ text/xhtml\n \n + \ \n \n gml:MultiCurveType\n + \ \n text/xml; subtype=gml/3.1.1\n + \ text/xhtml\n \n + \ \n \n gml:MultiSurfaceType\n + \ \n text/xml; subtype=gml/3.1.1\n + \ text/xhtml\n \n + \ \n \n gml:AbstractMetaDataType\n + \ \n text/xml; subtype=gml/3.1.1\n + \ text/xhtml\n \n + \ \n \n gml:AbstractTopologyType\n + \ \n text/xml; subtype=gml/3.1.1\n + \ text/xhtml\n \n + \ \n \n\n \n \n + \ \n \n \n \n + \ gml:Envelope\n gml:Point\n + \ gml:LineString\n gml:Polygon\n + \ gml:ArcByCenterPoint\n + \ gml:CircleByCenterPoint\n + \ gml:Arc\n gml:Circle\n + \ gml:ArcByBulge\n gml:Bezier\n + \ gml:Clothoid\n gml:CubicSpline\n + \ gml:Geodesic\n gml:OffsetCurve\n + \ gml:Triangle\n gml:PolyhedralSurface\n + \ gml:TriangulatedSurface\n + \ gml:Tin\n gml:Solid\n + \ \n \n \n \n \n \n + \ \n \n \n \n \n + \ \n \n + \ \n \n \n + \ \n LessThan\n + \ GreaterThan\n + \ LessThanEqualTo\n + \ GreaterThanEqualTo\n + \ EqualTo\n NotEqualTo\n + \ Like\n Between\n + \ NullCheck\n \n + \ \n \n + \ \n \n MIN\n MAX\n + \ SIN\n COS\n TAN\n + \ \n \n \n + \ \n \n \n + \ \n \n \n\n" + headers: + Accept-Ranges: + - bytes + Access-Control-Allow-Origin: + - '*' + Connection: + - Keep-Alive + Content-Security-Policy: + - upgrade-insecure-requests + Content-Type: + - application/xml + Date: + - Thu, 02 Oct 2025 15:20:41 GMT + ETag: + - '"442e-6324eaaf9e8ae-gzip"' + Keep-Alive: + - timeout=5, max=100 + Last-Modified: + - Wed, 09 Apr 2025 01:48:37 GMT + Server: + - Apache + Vary: + - upgrade-insecure-requests,Accept-Encoding + X-Hostname: + - (null) + content-length: + - '17454' + status: + code: 200 + message: OK +version: 1 diff --git a/src/openforms/emails/tests/vcr_cassettes/test_digest_functions/InvalidMapComponentOverlaysTests/test_valid_map_component_with_wms_overlays.yaml b/src/openforms/emails/tests/data/vcr_cassettes/InvalidMapComponentOverlaysTests/test_valid_map_component_with_wms_overlays.yaml similarity index 99% rename from src/openforms/emails/tests/vcr_cassettes/test_digest_functions/InvalidMapComponentOverlaysTests/test_valid_map_component_with_wms_overlays.yaml rename to src/openforms/emails/tests/data/vcr_cassettes/InvalidMapComponentOverlaysTests/test_valid_map_component_with_wms_overlays.yaml index 910e5d08a5..990ab2fd0f 100644 --- a/src/openforms/emails/tests/vcr_cassettes/test_digest_functions/InvalidMapComponentOverlaysTests/test_valid_map_component_with_wms_overlays.yaml +++ b/src/openforms/emails/tests/data/vcr_cassettes/InvalidMapComponentOverlaysTests/test_valid_map_component_with_wms_overlays.yaml @@ -239,7 +239,7 @@ interactions: Content-Type: - text/xml; charset=UTF-8 Date: - - Tue, 16 Sep 2025 09:45:20 GMT + - Thu, 02 Oct 2025 15:20:41 GMT Referrer-Policy: - origin Strict-Transport-Security: @@ -439,7 +439,7 @@ interactions: Content-Type: - application/xml Date: - - Tue, 16 Sep 2025 09:45:21 GMT + - Thu, 02 Oct 2025 15:20:42 GMT ETag: - '"337a-6324eaafa178e-gzip"' Keep-Alive: diff --git a/src/openforms/emails/tests/test_digest_functions.py b/src/openforms/emails/tests/test_digest_functions.py index c4d09e18fa..04ec426e9d 100644 --- a/src/openforms/emails/tests/test_digest_functions.py +++ b/src/openforms/emails/tests/test_digest_functions.py @@ -16,7 +16,10 @@ from openforms.config.constants import FamilyMembersDataAPIChoices from openforms.config.models import GlobalConfiguration -from openforms.config.tests.factories import MapWMSTileLayerFactory +from openforms.config.tests.factories import ( + MapWFSTileLayerFactory, + MapWMSTileLayerFactory, +) from openforms.contrib.brk.models import BRKConfig from openforms.contrib.brk.tests.base import BRK_SERVICE, INVALID_BRK_SERVICE from openforms.contrib.haal_centraal.constants import BRPVersions @@ -1743,8 +1746,14 @@ def test_form_with_different_component_does_not_trigger_checks(self): class InvalidMapComponentOverlaysTests(OFVCRMixin, TestCase): - v111_EXAMPLE = "https://schemas.opengis.net/wms/1.1.1/capabilities_1_1_1.xml" - v130_EXAMPLE = "https://service.pdok.nl/bzk/bro-grondwaterspiegeldiepte/wms/v2_0?request=GetCapabilities&service=WMS" + VCR_TEST_FILES = TEST_FILES + + WMS_v111_EXAMPLE = "https://schemas.opengis.net/wms/1.1.1/capabilities_1_1_1.xml" + WMS_v130_EXAMPLE = "https://service.pdok.nl/bzk/bro-grondwaterspiegeldiepte/wms/v2_0?request=GetCapabilities&service=WMS" + WFS_v110_EXAMPLE = ( + "https://schemas.opengis.net/wfs/1.1.0/examples/WFS_Capabilities_Sample.xml" + ) + WFS_v200_EXAMPLE = "https://service.pdok.nl/rws/nwbwegen/wfs/v1_0?request=GetCapabilities&service=WFS" def test_valid_map_component_without_overlays(self): FormFactory.create( @@ -1770,8 +1779,8 @@ def test_valid_map_component_without_overlays(self): self.assertEqual(len(invalid_map_component_overlays), 0) def test_valid_map_component_with_wms_overlays(self): - v130_wms_layer = MapWMSTileLayerFactory.create(url=self.v130_EXAMPLE) - v110_wms_layer = MapWMSTileLayerFactory.create(url=self.v111_EXAMPLE) + v130_wms_layer = MapWMSTileLayerFactory.create(url=self.WMS_v130_EXAMPLE) + v110_wms_layer = MapWMSTileLayerFactory.create(url=self.WMS_v111_EXAMPLE) FormFactory.create( generate_minimal_setup=True, formstep__form_definition__configuration={ @@ -1909,8 +1918,8 @@ def test_invalid_map_component_with_unreachable_wms_overlay_url(self): invalid_map_component_overlays[0], ) - def test_invalid_map_component_with_multiple_unknown_overlays_layers(self): - wms_layer = MapWMSTileLayerFactory.create(url=self.v130_EXAMPLE) + def test_invalid_map_component_with_multiple_unknown_wms_overlays_layers(self): + wms_layer = MapWMSTileLayerFactory.create(url=self.WMS_v130_EXAMPLE) form = FormFactory.create( generate_minimal_setup=True, formstep__form_definition__configuration={ @@ -1973,3 +1982,208 @@ def test_invalid_map_component_with_multiple_unknown_overlays_layers(self): ), invalid_map_component_overlays[1], ) + + def test_valid_map_component_with_wfs_overlays(self): + v200_wfs_layer = MapWFSTileLayerFactory.create(url=self.WFS_v200_EXAMPLE) + v110_wfs_layer = MapWFSTileLayerFactory.create(url=self.WFS_v110_EXAMPLE) + FormFactory.create( + generate_minimal_setup=True, + formstep__form_definition__configuration={ + "components": [ + { + "type": "map", + "key": "map", + "defaultZoom": 3, + "initialCenter": { + "lat": 43.23, + "lng": 41.23, + }, + "overlays": [ + { + "type": "wfs", + "uuid": str(v200_wfs_layer.uuid), + "label": "My first overlay", + # Layers WFS_v200_EXAMPLE + "layers": ["nwbwegen:wegvakken"], + }, + { + "type": "wfs", + "uuid": str(v110_wfs_layer.uuid), + "label": "My second overlay", + # Layers from WFS_v110_EXAMPLE + "layers": ["bo:WoodsType"], + }, + ], + } + ] + }, + ) + + # Collect invalid map component overlays + invalid_map_component_overlays = collect_invalid_map_component_overlays() + + self.assertEqual(len(invalid_map_component_overlays), 0) + + def test_invalid_map_component_with_unknown_wfs_overlay(self): + form = FormFactory.create( + generate_minimal_setup=True, + formstep__form_definition__configuration={ + "components": [ + { + "type": "map", + "key": "map", + "defaultZoom": 3, + "initialCenter": { + "lat": 43.23, + "lng": 41.23, + }, + "overlays": [ + { + "type": "wfs", + # Unknown tile layer uuid + "uuid": "ca0fd363-917a-41cd-ad93-2c5ae99d82d9", + "label": "My first overlay", + # Layers WFS_v200_EXAMPLE + "layers": ["nwbwegen:wegvakken"], + } + ], + } + ] + }, + ) + + # Collect invalid map component overlays + invalid_map_component_overlays = collect_invalid_map_component_overlays() + + self.assertEqual(len(invalid_map_component_overlays), 1) + self.assertEqual( + InvalidMapComponentOverlay( + form_id=form.id, + form_name=form.name, + component_name="map", + overlay_name="My first overlay", + exception_message=_("Invalid UUID"), + ), + invalid_map_component_overlays[0], + ) + + with self.subTest("Link to affected form"): + # Link to form + form_relative_admin_url = reverse( + "admin:forms_form_change", kwargs={"object_id": form.id} + ) + absolute_uri_to_form = build_absolute_uri(form_relative_admin_url) + self.assertEqual( + absolute_uri_to_form, + invalid_map_component_overlays[0].admin_link, + ) + + def test_invalid_map_component_with_unreachable_wfs_overlay_url(self): + # domain does not exist + wfs_layer = MapWFSTileLayerFactory.create(url="http://bad-host:9999/fake-wfs") + form = FormFactory.create( + generate_minimal_setup=True, + formstep__form_definition__configuration={ + "components": [ + { + "type": "map", + "key": "map", + "defaultZoom": 3, + "initialCenter": { + "lat": 43.23, + "lng": 41.23, + }, + "overlays": [ + { + "type": "wfs", + "uuid": str(wfs_layer.uuid), + "label": "My first overlay", + # Layers WFS_v200_EXAMPLE + "layers": ["nwbwegen:wegvakken"], + } + ], + } + ] + }, + ) + + # Collect invalid map component overlays + with self.vcr_raises(): + invalid_map_component_overlays = collect_invalid_map_component_overlays() + + self.assertEqual(len(invalid_map_component_overlays), 1) + self.assertEqual( + InvalidMapComponentOverlay( + form_id=form.id, + form_name=form.name, + component_name="map", + overlay_name="My first overlay", + exception_message=_("Overlay url returned an error"), + ), + invalid_map_component_overlays[0], + ) + + def test_invalid_map_component_with_multiple_unknown_wfs_overlays_layers(self): + wfs_layer = MapWFSTileLayerFactory.create(url=self.WFS_v200_EXAMPLE) + form = FormFactory.create( + generate_minimal_setup=True, + formstep__form_definition__configuration={ + "components": [ + { + "type": "map", + "key": "map", + "defaultZoom": 3, + "initialCenter": { + "lat": 43.23, + "lng": 41.23, + }, + "overlays": [ + { + "type": "wfs", + "uuid": str(wfs_layer.uuid), + "label": "My first overlay", + # Layers aren't present in XML + "layers": ["unknown-layer", "another-unknown-layer"], + }, + { + "type": "wfs", + "uuid": str(wfs_layer.uuid), + "label": "My second overlay", + # Layer isn't present in XML + "layers": ["a-third-unknown-layer"], + }, + ], + } + ] + }, + ) + + # Collect invalid map component overlays + invalid_map_component_overlays = collect_invalid_map_component_overlays() + + invalid_layers = ("another-unknown-layer", "unknown-layer") + self.assertEqual(len(invalid_map_component_overlays), 2) + self.assertEqual( + InvalidMapComponentOverlay( + form_id=form.id, + form_name=form.name, + component_name="map", + overlay_name="My first overlay", + exception_message=_("Overlay uses unavailable layers: {layers}").format( + layers=", ".join(sorted(invalid_layers)) + ), + ), + invalid_map_component_overlays[0], + ) + self.assertEqual( + InvalidMapComponentOverlay( + form_id=form.id, + form_name=form.name, + component_name="map", + overlay_name="My second overlay", + exception_message=_("Overlay uses unavailable layers: {layers}").format( + layers=", ".join(("a-third-unknown-layer",)) + ), + ), + invalid_map_component_overlays[1], + ) diff --git a/src/openforms/fixtures/default_admin_index.json b/src/openforms/fixtures/default_admin_index.json index f0478e3e4f..c4633f72f3 100644 --- a/src/openforms/fixtures/default_admin_index.json +++ b/src/openforms/fixtures/default_admin_index.json @@ -158,6 +158,10 @@ "config", "maptilelayer" ], + [ + "config", + "mapwfstilelayer" + ], [ "config", "mapwmstilelayer" diff --git a/src/openforms/fixtures/default_map_wfs_tile_layers.json b/src/openforms/fixtures/default_map_wfs_tile_layers.json new file mode 100644 index 0000000000..57f47379ab --- /dev/null +++ b/src/openforms/fixtures/default_map_wfs_tile_layers.json @@ -0,0 +1,9 @@ +[ + { + "model": "config.mapwfstilelayer", + "fields": { + "name": "Interactief Basisregistratie Adressen en Gebouwen (BAG)", + "url": "https://service.pdok.nl/lv/bag/wfs/v2_0?request=getCapabilities&service=WFS" + } + } +] diff --git a/src/openforms/formio/components/custom.py b/src/openforms/formio/components/custom.py index c2ecd9ae60..d0bba5c47d 100644 --- a/src/openforms/formio/components/custom.py +++ b/src/openforms/formio/components/custom.py @@ -1,8 +1,7 @@ import re -from collections.abc import Mapping from copy import deepcopy from datetime import datetime -from typing import Protocol +from typing import Protocol, cast from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator from django.utils import timezone @@ -21,7 +20,12 @@ ) from openforms.authentication.service import AuthAttribute from openforms.config.constants import FamilyMembersDataAPIChoices -from openforms.config.models import GlobalConfiguration, MapTileLayer, MapWMSTileLayer +from openforms.config.models import ( + GlobalConfiguration, + MapTileLayer, + MapWFSTileLayer, + MapWMSTileLayer, +) from openforms.formio.typing.map import Overlay from openforms.forms.models import FormVariable from openforms.prefill.contrib.family_members.plugin import ( @@ -254,21 +258,32 @@ def mutate_config_dynamically( if overlays := component.get("overlays", []): # inject the map layer URLs for the SDK - wms_uuids = ( + def get_layers( + model: type[MapWMSTileLayer | MapWFSTileLayer], uuids: list[str] + ) -> dict[str, str]: + return { + str(uuid): str(url) + for (uuid, url) in model.objects.filter(uuid__in=uuids).values_list( + "uuid", "url" + ) + } + + wms_uuids = [ overlay["uuid"] for overlay in overlays if overlay["type"] == "wms" - ) - wms_layers: Mapping[str, str] = { - str(uuid): str(url) - for (uuid, url) in MapWMSTileLayer.objects.filter( - uuid__in=wms_uuids - ).values_list("uuid", "url") + ] + wfs_uuids = [ + overlay["uuid"] for overlay in overlays if overlay["type"] == "wfs" + ] + + all_layers = { + **get_layers(MapWMSTileLayer, wms_uuids), + **get_layers(MapWFSTileLayer, wfs_uuids), } updated_overlays: list[Overlay] = [ - {**overlay, "url": wms_layer_url} + cast(Overlay, {**overlay, "url": all_layers[overlay["uuid"]]}) for overlay in overlays - # only keep overlays that don't have stale UUID references - if (wms_layer_url := wms_layers.get(overlay["uuid"])) + if overlay["uuid"] in all_layers ] component["overlays"] = updated_overlays diff --git a/src/openforms/formio/tests/test_dynamic_config.py b/src/openforms/formio/tests/test_dynamic_config.py index 34b897cb82..6a164d5dda 100644 --- a/src/openforms/formio/tests/test_dynamic_config.py +++ b/src/openforms/formio/tests/test_dynamic_config.py @@ -5,7 +5,11 @@ from rest_framework.test import APIRequestFactory from openforms.config.models import GlobalConfiguration -from openforms.config.tests.factories import MapTileLayerFactory, MapWMSTileLayerFactory +from openforms.config.tests.factories import ( + MapTileLayerFactory, + MapWFSTileLayerFactory, + MapWMSTileLayerFactory, +) from openforms.formio.datastructures import FormioConfigurationWrapper from openforms.formio.dynamic_config import ( rewrite_formio_components, @@ -389,3 +393,102 @@ def test_map_with_unknown_WMS_overlay(self): "overlays": [], } self.assertEqual(configuration["components"][0], expected) + + def test_map_with_known_WFS_overlay(self): + MapWFSTileLayerFactory.create( + uuid="1266c027-9a18-4ecb-8a9e-6acddf7e74f3", url="https://example.wfs.com" + ) + configuration = { + "components": [ + { + "type": "map", + "key": "map", + "defaultZoom": 3, + "initialCenter": { + "lat": 43.23, + "lng": 41.23, + }, + "overlays": [ + { + "type": "wfs", + "uuid": "1266c027-9a18-4ecb-8a9e-6acddf7e74f3", + "name": "My first overlay", + "layers": ["layer1", "layer2"], + } + ], + } + ] + } + + formio_configuration = FormioConfigurationWrapper(configuration) + submission = SubmissionFactory.create() + rewrite_formio_components(formio_configuration, submission) + + request = rf.get("/dummy") + rewrite_formio_components_for_request(formio_configuration, request) + + # Expect that the overlay has the "url" attribute with the value of the WFS tile + # layer url. + expected = { + "type": "map", + "key": "map", + "defaultZoom": 3, + "initialCenter": { + "lat": 43.23, + "lng": 41.23, + }, + "overlays": [ + { + "type": "wfs", + "uuid": "1266c027-9a18-4ecb-8a9e-6acddf7e74f3", + "name": "My first overlay", + "layers": ["layer1", "layer2"], + "url": "https://example.wfs.com", + } + ], + } + self.assertEqual(configuration["components"][0], expected) + + def test_map_with_unknown_WFS_overlay(self): + configuration = { + "components": [ + { + "type": "map", + "key": "map", + "defaultZoom": 3, + "initialCenter": { + "lat": 43.23, + "lng": 41.23, + }, + "overlays": [ + { + "type": "wfs", + # Some unknown uuid + "uuid": "44c9ee90-96a3-4ac2-bb55-f2f42b547b15", + "name": "My first overlay", + "layers": ["layer1", "layer2"], + } + ], + } + ] + } + + formio_configuration = FormioConfigurationWrapper(configuration) + submission = SubmissionFactory.create() + rewrite_formio_components(formio_configuration, submission) + + request = rf.get("/dummy") + rewrite_formio_components_for_request(formio_configuration, request) + + # Expect that the invalid overlay is removed from the component. + expected = { + "type": "map", + "key": "map", + "defaultZoom": 3, + "initialCenter": { + "lat": 43.23, + "lng": 41.23, + }, + "overlays": [], + } + self.assertEqual(configuration["components"][0], expected) diff --git a/src/openforms/formio/typing/map.py b/src/openforms/formio/typing/map.py index a780137635..7f5468a578 100644 --- a/src/openforms/formio/typing/map.py +++ b/src/openforms/formio/typing/map.py @@ -12,9 +12,12 @@ class MapInteractions(TypedDict): polyline: bool +type OverlayType = Literal["wms", "wfs"] + + class Overlay(TypedDict): uuid: str label: str url: NotRequired[str] # added in dynamically - type: Literal["wms", "wfs"] + type: OverlayType layers: list[str] diff --git a/src/openforms/forms/admin/mixins.py b/src/openforms/forms/admin/mixins.py index 9ef207bf40..009ec60921 100644 --- a/src/openforms/forms/admin/mixins.py +++ b/src/openforms/forms/admin/mixins.py @@ -8,6 +8,7 @@ from openforms.config.models import ( GlobalConfiguration, MapTileLayer, + MapWFSTileLayer, MapWMSTileLayer, RichTextColor, ) @@ -26,10 +27,14 @@ def get_map_tile_layers(): return list(MapTileLayer.objects.values("identifier", "url", "label")) -def get_wms_layers(): +def get_map_wms_layers(): return list(MapWMSTileLayer.objects.values("uuid", "name", "url")) +def get_map_wfs_layers(): + return list(MapWFSTileLayer.objects.values("uuid", "name", "url")) + + class FormioConfigMixin: def render_change_form( self, request, context, add=False, change=False, form_url="", obj=None @@ -40,7 +45,8 @@ def render_change_form( "required_default": config.form_fields_required_default, "rich_text_colors": get_rich_text_colors(), "map_tile_layers": get_map_tile_layers(), - "wms_layers": get_wms_layers(), + "map_wms_layers": get_map_wms_layers(), + "map_wfs_layers": get_map_wfs_layers(), "upload_filetypes": [ {"label": label, "value": value} for value, label in UploadFileType.choices diff --git a/src/openforms/forms/templates/admin/forms/includes/formio_config.html b/src/openforms/forms/templates/admin/forms/includes/formio_config.html index cfce242f4f..ec24d31dab 100644 --- a/src/openforms/forms/templates/admin/forms/includes/formio_config.html +++ b/src/openforms/forms/templates/admin/forms/includes/formio_config.html @@ -2,6 +2,7 @@ {{ required_default|json_script:'config-REQUIRED_DEFAULT' }} {{ rich_text_colors|json_script:'config-RICH_TEXT_COLORS' }} {{ map_tile_layers|json_script:'config-MAP_TILE_LAYERS' }} -{{ wms_layers|json_script:'config-MAP_WMS_LAYERS' }} +{{ map_wms_layers|json_script:'config-MAP_WMS_LAYERS' }} +{{ map_wfs_layers|json_script:'config-MAP_WFS_LAYERS' }} {{ upload_filetypes|json_script:'config-UPLOAD_FILETYPES' }} {{ confidentiality_levels|json_script:'CONFIDENTIALITY_LEVELS' }} diff --git a/src/openforms/js/components/formio_builder/mapLayers.js b/src/openforms/js/components/formio_builder/mapLayers.js index a0c77cd895..8535c2d81c 100644 --- a/src/openforms/js/components/formio_builder/mapLayers.js +++ b/src/openforms/js/components/formio_builder/mapLayers.js @@ -1,7 +1,7 @@ import jsonScriptToVar from 'utils/json-script'; const MAP_WMS_LAYERS = jsonScriptToVar('config-MAP_WMS_LAYERS', {default: []}); -const MAP_WFS_LAYERS = []; +const MAP_WFS_LAYERS = jsonScriptToVar('config-MAP_WFS_LAYERS', {default: []}); export const getMapOverlayTileLayers = async () => { const layers = [