From cfb8bcd1b9991e12167f86d92f63ab3034b06d2e Mon Sep 17 00:00:00 2001 From: robinvandermolen Date: Thu, 2 Oct 2025 12:29:25 +0200 Subject: [PATCH 1/5] :sparkles: [#5576] Add map WFS tile layers to admin WFS tile layers are similar to WMS tile layers, in the way that they both allow additional information to be presented in a map component. WFS takes it one step further, to allow the user to interact with the added visuals. The WFS version of BAG has been added as default WFS tile layer, to quickly let users become familiar with the new functionality. The WFS tile layers can be imported and exported from the WFS tile layers configuration page in the admin. This allows quick and easy sharing of WFS tile layers between OF instances. --- src/openforms/config/admin.py | 12 +++- .../config/migrations/0064_mapwfstilelayer.py | 67 +++++++++++++++++++ src/openforms/config/models/__init__.py | 3 +- src/openforms/config/models/map.py | 43 ++++++++++++ src/openforms/config/resources.py | 10 ++- .../fixtures/default_admin_index.json | 4 ++ .../fixtures/default_map_wfs_tile_layers.json | 9 +++ 7 files changed, 145 insertions(+), 3 deletions(-) create mode 100644 src/openforms/config/migrations/0064_mapwfstilelayer.py create mode 100644 src/openforms/fixtures/default_map_wfs_tile_layers.json 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/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" + } + } +] From 556b3ca1a4326b46bef30f9e98143fdf2f8b5c36 Mon Sep 17 00:00:00 2001 From: robinvandermolen Date: Thu, 2 Oct 2025 12:35:26 +0200 Subject: [PATCH 2/5] :sparkles: [#5576] Expose WFS tile layers to form builder Just like the background layers and WMS layers, this is exposed through a simple JSON object dumped in the template context - there is no need to add additional API endpoints. --- src/openforms/forms/admin/mixins.py | 10 ++++++++-- .../templates/admin/forms/includes/formio_config.html | 3 ++- .../js/components/formio_builder/mapLayers.js | 2 +- 3 files changed, 11 insertions(+), 4 deletions(-) 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 = [ From c6dcfe4c1b8054bdb6870ef727afb693c81e5f5e Mon Sep 17 00:00:00 2001 From: robinvandermolen Date: Thu, 2 Oct 2025 15:16:33 +0200 Subject: [PATCH 3/5] :sparkles: [#5576] Dynamically get WFS tile layer url To showcase the map component overlays, we need to know which url corresponds to the selected tile layer. By fetching the url before the component is shown in the form, ensure that we have the current and correct url. --- src/openforms/config/tests/factories.py | 8 ++ src/openforms/formio/components/custom.py | 41 ++++--- .../formio/tests/test_dynamic_config.py | 105 +++++++++++++++++- 3 files changed, 140 insertions(+), 14 deletions(-) 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/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) From efcb72db36c4171570f4858e1da03bf78eeb9f2a Mon Sep 17 00:00:00 2001 From: robinvandermolen Date: Thu, 2 Oct 2025 17:26:16 +0200 Subject: [PATCH 4/5] :children_crossing: [#5576] Add email digest for WFS map overlays Map component configuration can become invalid in multiple ways: - tile layer source that updates its available layers/feature types - tile layers that are removed in OF - tile layer sources that are no-longer available The email digest for WMS has been transformed into a generic map overlay check, which covers WMS and WFS. Internally WMS and WFS are still handled separately, because of their different XML structures and rules. --- src/openforms/emails/digest.py | 132 ++++-- ..._multiple_unknown_wfs_overlays_layers.yaml | 154 +++++++ ...multiple_unknown_wms_overlays_layers.yaml} | 2 +- ...valid_map_component_with_wfs_overlays.yaml | 388 ++++++++++++++++++ ...valid_map_component_with_wms_overlays.yaml | 4 +- .../emails/tests/test_digest_functions.py | 228 +++++++++- src/openforms/formio/typing/map.py | 5 +- 7 files changed, 875 insertions(+), 38 deletions(-) create mode 100644 src/openforms/emails/tests/data/vcr_cassettes/InvalidMapComponentOverlaysTests/test_invalid_map_component_with_multiple_unknown_wfs_overlays_layers.yaml rename src/openforms/emails/tests/{vcr_cassettes/test_digest_functions/InvalidMapComponentOverlaysTests/test_invalid_map_component_with_multiple_unknown_overlays_layers.yaml => data/vcr_cassettes/InvalidMapComponentOverlaysTests/test_invalid_map_component_with_multiple_unknown_wms_overlays_layers.yaml} (99%) create mode 100644 src/openforms/emails/tests/data/vcr_cassettes/InvalidMapComponentOverlaysTests/test_valid_map_component_with_wfs_overlays.yaml rename src/openforms/emails/tests/{vcr_cassettes/test_digest_functions => data/vcr_cassettes}/InvalidMapComponentOverlaysTests/test_valid_map_component_with_wms_overlays.yaml (99%) diff --git a/src/openforms/emails/digest.py b/src/openforms/emails/digest.py index f986f0112d..6e3b9e14a1 100644 --- a/src/openforms/emails/digest.py +++ b/src/openforms/emails/digest.py @@ -1,9 +1,10 @@ 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 itertools import chain, groupby +from typing import assert_never from django.contrib.contenttypes.models import ContentType from django.db.models import Q @@ -17,13 +18,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 +39,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 +699,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 +719,93 @@ 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, layer_type: OverlayType + ) -> list[_Element] | None: + match layer_type: + case "wms": + return self._get_wms_layer_names(root) + case "wfs": + return self._get_wfs_layer_names(root) + case _: + assert_never(layer_type) + + @staticmethod + def _get_wms_layer_names(root: type[_Element] | None) -> list[_Element] | None: + # Try with common wms standard namespace first (used in WMS 1.3.0) + return 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") + + @staticmethod + def _get_wfs_layer_names(root: type[_Element] | None) -> list[_Element] | None: + # Try with common wfs standard namespace for WFS 2.0 + return ( + root.findall( + ".//wfs:FeatureType/wfs:Name", + namespaces={"wfs": "http://www.opengis.net/wfs/2.0"}, + ) + # Fallback to common namespace for WFS 1.x + or root.findall( + ".//wfs:FeatureType/wfs:Name", + namespaces={"wfs": "http://www.opengis.net/wfs"}, + ) + # Fallback to wfs without common namespaces + or root.findall(".//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) + + 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( + names = self._get_layer_names(root, 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 +813,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 +833,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 +851,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 +866,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/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] From 2597156da699220ae69016e67840e3d508ab3893 Mon Sep 17 00:00:00 2001 From: robinvandermolen Date: Tue, 7 Oct 2025 17:01:41 +0200 Subject: [PATCH 5/5] :children_crossing: [#5576] Improve email digest with actual map overlay namespaces By using the namespaces that are actually set on the WMS and WFS tile layers, we can support more sources than just openGIS. In addition, by checking the availability of the namespaces, we can target the xml content more accurately. --- src/openforms/emails/digest.py | 65 +++++++++++++++++++++------------- 1 file changed, 41 insertions(+), 24 deletions(-) diff --git a/src/openforms/emails/digest.py b/src/openforms/emails/digest.py index 6e3b9e14a1..d9a20cfca9 100644 --- a/src/openforms/emails/digest.py +++ b/src/openforms/emails/digest.py @@ -3,6 +3,7 @@ from collections.abc import Collection, Iterable, Iterator, MutableMapping from dataclasses import dataclass from datetime import datetime, timedelta +from io import BytesIO from itertools import chain, groupby from typing import assert_never @@ -720,41 +721,46 @@ def __exit__(self, *args): self.session.__exit__(*args) def _get_layer_names( - self, root: type[_Element] | None, layer_type: OverlayType + 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) + return self._get_wms_layer_names(root, namespaces) case "wfs": - return self._get_wfs_layer_names(root) + return self._get_wfs_layer_names(root, namespaces) case _: assert_never(layer_type) @staticmethod - def _get_wms_layer_names(root: type[_Element] | None) -> list[_Element] | None: - # Try with common wms standard namespace first (used in WMS 1.3.0) - return root.findall( - ".//wms:Layer/wms:Name", - namespaces={"wms": "http://www.opengis.net/wms"}, + 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) - ) or root.findall(".//Layer/Name") + return root.findall(path=".//Layer/Name") @staticmethod - def _get_wfs_layer_names(root: type[_Element] | None) -> list[_Element] | None: - # Try with common wfs standard namespace for WFS 2.0 - return ( - root.findall( - ".//wfs:FeatureType/wfs:Name", - namespaces={"wfs": "http://www.opengis.net/wfs/2.0"}, - ) - # Fallback to common namespace for WFS 1.x - or root.findall( - ".//wfs:FeatureType/wfs:Name", - namespaces={"wfs": "http://www.opengis.net/wfs"}, + 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, ) - # Fallback to wfs without common namespaces - or root.findall(".//FeatureType/Name") - ) + 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 @@ -781,6 +787,16 @@ def _set_result_into_cache( 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]: @@ -796,7 +812,8 @@ def get_layer_names( cache_result = exc else: root = etree.fromstring(response.content) - names = self._get_layer_names(root, layer_type) + 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