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 = [