diff --git a/.env.example b/.env.example
index 2b1feca7..3875ece0 100644
--- a/.env.example
+++ b/.env.example
@@ -12,6 +12,11 @@ ORTHOPHOTO_RESOLUTION_M=1.0
ORTHOPHOTO_MIN_SIDE_M=128
ORTHOPHOTO_MAX_SIDE_M=1024
ORTHOPHOTO_CACHE_TTL_HOURS=24
+SOURCE_CATALOG_PROBE_ENABLED=true
+SOURCE_CATALOG_GRB_WFS_URL=https://geo.api.vlaanderen.be/GRB/wfs
+SOURCE_CATALOG_PROBE_TIMEOUT_SECONDS=10
+SOURCE_CATALOG_PROBE_MAX_RESPONSE_MB=2
+SOURCE_CATALOG_PROBE_CACHE_TTL_SECONDS=900
DHMV_ENABLED=true
DHMV_WCS_URL=https://geo.api.vlaanderen.be/DHMV/wcs
DHMV_RESOLUTION_M=5.0
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 19d8330b..e623c765 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,18 @@
# Changelog
+## Sprint 222 Official source edition probes (2026-07-16)
+
+- Added explicit, read-only GRB and most-recent orthophoto catalog probes using
+ official WFS/WMS capabilities and linked ISO 19139 CSW metadata records.
+- Added bounded response sizes, timeouts, provider-isolated failures, a short
+ cache and strict metadata-host/path validation. No provider features, raster
+ pixels or application data are fetched or modified.
+- Added canonical API, compact opt-in Status UI and operator CLI support. The
+ local source-freshness audit remains automatic and provider-free.
+- Added deterministic XML/network-boundary tests and editable Compose/Unraid
+ controls without changing migrations or persistence contracts.
+
## Sprint 221 Governed source freshness audit (2026-07-16)
- Added a project-wide read-only source report derived from Dataset,
diff --git a/backend/README.md b/backend/README.md
index 8ce811bc..af97d7d3 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -1443,3 +1443,29 @@ docker exec geointel python /app/scripts/audit_source_freshness.py \
Use `--fail-on due` to make a planned review date fail automation, or
`--fail-on never` for reporting only. The command never starts a refresh.
+
+An operator can explicitly add the official GRB and orthophoto edition check:
+
+```bash
+docker exec geointel python /app/scripts/audit_source_freshness.py \
+ --project-id 82a85913-c522-45d7-84a1-02b393d89e55 \
+ --api-url http://127.0.0.1/api/v1 \
+ --probe-catalogs \
+ --output /app/storage/operator-evidence/source-freshness/with-catalogs.json
+```
+
+Use `--refresh-catalogs` to bypass the 15-minute in-memory cache and
+`--fail-on-catalog` only when temporary official-provider unavailability must
+fail an operator job. This path reads bounded WFS/WMS capabilities and their
+fixed ISO 19139 metadata records. It confirms GRB `GBG`, `WBN`, `WGO`, `ADP`
+and orthophoto `Ortho`, `Vliegdagcontour`; it never requests feature or raster
+content. The public Datavindplaats API requires an access token, so release
+evidence comes from the public metadata links published by the official OGC
+services rather than HTML scraping.
+
+Runtime controls are `SOURCE_CATALOG_PROBE_ENABLED`,
+`SOURCE_CATALOG_GRB_WFS_URL`, `SOURCE_CATALOG_PROBE_TIMEOUT_SECONDS`,
+`SOURCE_CATALOG_PROBE_MAX_RESPONSE_MB` and
+`SOURCE_CATALOG_PROBE_CACHE_TTL_SECONDS`. Metadata links remain restricted to
+the official HTTPS CSW path on `metadata.vlaanderen.be` even when an operator
+overrides the capabilities endpoint.
diff --git a/backend/app/api/routes/datasets.py b/backend/app/api/routes/datasets.py
index a6c31b40..212c4cff 100644
--- a/backend/app/api/routes/datasets.py
+++ b/backend/app/api/routes/datasets.py
@@ -45,6 +45,7 @@ from app.services.vector_operations_service import VectorOperationsService
from app.services.vector_feature_service import VectorFeatureService
from app.services.dataset_service import DatasetService
from app.services.source_freshness_service import SourceFreshnessService
+from app.services.source_catalog_probe_service import SourceCatalogProbeService
from app.services.orthophoto_acquisition_service import OrthophotoAcquisitionService
from app.services.dhmv_acquisition_service import DhmvAcquisitionService
from app.services.terrain_analysis_service import TerrainAnalysisService
@@ -255,6 +256,16 @@ def audit_dataset_source_freshness(
return envelope(report.model_dump())
+@router.get("/datasets/source-catalog-probes", response_model=dict)
+def probe_dataset_source_catalogs(
+ project_id: UUID,
+ refresh: bool = Query(default=False),
+ db: Session = Depends(get_db),
+):
+ report = SourceCatalogProbeService.audit_project(db, project_id, force=refresh)
+ return envelope(report.model_dump())
+
+
@router.get("/datasets/{dataset_id}", response_model=dict)
def get_dataset(
project_id: UUID,
diff --git a/backend/app/core/config.py b/backend/app/core/config.py
index 83af58c3..ca22b1fd 100644
--- a/backend/app/core/config.py
+++ b/backend/app/core/config.py
@@ -31,6 +31,29 @@ class Settings(BaseSettings):
orthophoto_timeout_seconds: int = Field(default=120, ge=1, validation_alias="ORTHOPHOTO_TIMEOUT_SECONDS")
orthophoto_max_response_mb: int = Field(default=32, ge=1, validation_alias="ORTHOPHOTO_MAX_RESPONSE_MB")
orthophoto_cache_ttl_hours: int = Field(default=24, ge=0, validation_alias="ORTHOPHOTO_CACHE_TTL_HOURS")
+ source_catalog_probe_enabled: bool = Field(default=True, validation_alias="SOURCE_CATALOG_PROBE_ENABLED")
+ source_catalog_grb_wfs_url: str = Field(
+ default="https://geo.api.vlaanderen.be/GRB/wfs",
+ validation_alias="SOURCE_CATALOG_GRB_WFS_URL",
+ )
+ source_catalog_probe_timeout_seconds: int = Field(
+ default=10,
+ ge=1,
+ le=60,
+ validation_alias="SOURCE_CATALOG_PROBE_TIMEOUT_SECONDS",
+ )
+ source_catalog_probe_max_response_mb: int = Field(
+ default=2,
+ ge=1,
+ le=10,
+ validation_alias="SOURCE_CATALOG_PROBE_MAX_RESPONSE_MB",
+ )
+ source_catalog_probe_cache_ttl_seconds: int = Field(
+ default=900,
+ ge=0,
+ le=86_400,
+ validation_alias="SOURCE_CATALOG_PROBE_CACHE_TTL_SECONDS",
+ )
dhmv_enabled: bool = Field(default=True, validation_alias="DHMV_ENABLED")
dhmv_wcs_url: str = Field(
default="https://geo.api.vlaanderen.be/DHMV/wcs",
diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py
index 610db024..9bc80ab4 100644
--- a/backend/app/schemas/__init__.py
+++ b/backend/app/schemas/__init__.py
@@ -11,6 +11,11 @@ from .source_freshness import (
SourceFreshnessSummary,
SourceIntegritySummary,
)
+from .source_catalog import (
+ SourceCatalogProbeItem,
+ SourceCatalogProbeReport,
+ SourceCatalogProbeSummary,
+)
from .detection import (
DetectionListResponse,
DetectionModelCapability,
@@ -141,6 +146,9 @@ __all__ = [
"SourceFreshnessReport",
"SourceFreshnessSummary",
"SourceIntegritySummary",
+ "SourceCatalogProbeItem",
+ "SourceCatalogProbeReport",
+ "SourceCatalogProbeSummary",
"DetectionListResponse",
"DetectionModelCapability",
"DetectionModelsResponse",
diff --git a/backend/app/schemas/source_catalog.py b/backend/app/schemas/source_catalog.py
new file mode 100644
index 00000000..664b5baa
--- /dev/null
+++ b/backend/app/schemas/source_catalog.py
@@ -0,0 +1,56 @@
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Literal
+from uuid import UUID
+
+from pydantic import BaseModel
+
+
+SourceCatalogProbeStatus = Literal["available", "degraded", "unavailable", "disabled"]
+SourceCatalogComparisonStatus = Literal["same", "different", "not_comparable", "no_local_data", "unavailable"]
+
+
+class SourceCatalogProbeItem(BaseModel):
+ source_name: str
+ display_name: str
+ service_type: Literal["WFS", "WMS"]
+ endpoint_url: str
+ status: SourceCatalogProbeStatus
+ reachable: bool
+ checked_at: datetime
+ cached: bool = False
+ expected_layers: list[str]
+ matched_layers: list[str]
+ missing_layers: list[str]
+ advertised_layer_count: int
+ metadata_url: str | None = None
+ metadata_identifier: str | None = None
+ remote_title: str | None = None
+ remote_version: str | None = None
+ remote_modified_at: datetime | None = None
+ remote_published_at: datetime | None = None
+ local_source_version: str | None = None
+ comparison_status: SourceCatalogComparisonStatus
+ capabilities_sha256: str | None = None
+ capabilities_etag: str | None = None
+ capabilities_last_modified_at: datetime | None = None
+ message: str
+ error_code: str | None = None
+
+
+class SourceCatalogProbeSummary(BaseModel):
+ provider_count: int
+ available_count: int
+ degraded_count: int
+ unavailable_count: int
+ disabled_count: int
+ different_version_count: int
+
+
+class SourceCatalogProbeReport(BaseModel):
+ project_id: UUID
+ generated_at: datetime
+ summary: SourceCatalogProbeSummary
+ items: list[SourceCatalogProbeItem]
+ limitations: list[str]
diff --git a/backend/app/services/source_catalog_probe_service.py b/backend/app/services/source_catalog_probe_service.py
new file mode 100644
index 00000000..fae36671
--- /dev/null
+++ b/backend/app/services/source_catalog_probe_service.py
@@ -0,0 +1,545 @@
+from __future__ import annotations
+
+from dataclasses import dataclass, replace
+from datetime import datetime, timedelta, timezone
+from email.utils import parsedate_to_datetime
+from hashlib import sha256
+import re
+from threading import Lock
+from typing import Any, Callable
+from urllib.error import HTTPError, URLError
+from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
+from urllib.request import Request, urlopen
+from uuid import UUID
+from xml.etree import ElementTree
+
+from sqlalchemy.orm import Session
+
+from app.core.config import Settings, get_settings
+from app.core.errors import AppError
+from app.models import Dataset, Project
+from app.schemas.source_catalog import (
+ SourceCatalogProbeItem,
+ SourceCatalogProbeReport,
+ SourceCatalogProbeSummary,
+)
+
+
+_GMD = "http://www.isotc211.org/2005/gmd"
+_GCO = "http://www.isotc211.org/2005/gco"
+_WFS = "http://www.opengis.net/wfs/2.0"
+_XLINK = "http://www.w3.org/1999/xlink"
+_METADATA_HOST = "metadata.vlaanderen.be"
+_VERSION_DATE = re.compile(r"^(?:toestand\s+)?(\d{4}-\d{2}-\d{2})$", re.IGNORECASE)
+_ORTHOPHOTO_EDITION = re.compile(r"^\d{4}\.\d{2}$")
+
+
+class CatalogProbeFailure(RuntimeError):
+ def __init__(self, code: str, message: str) -> None:
+ super().__init__(message)
+ self.code = code
+ self.message = message
+
+
+@dataclass(frozen=True)
+class ProbeContract:
+ source_name: str
+ display_name: str
+ service_type: str
+ endpoint_url: str
+ expected_layers: tuple[str, ...]
+
+
+@dataclass(frozen=True)
+class FetchResult:
+ content: bytes
+ content_type: str
+ etag: str | None
+ last_modified_at: datetime | None
+ final_url: str
+
+
+@dataclass(frozen=True)
+class RemoteProbe:
+ status: str
+ reachable: bool
+ checked_at: datetime
+ expected_layers: tuple[str, ...]
+ matched_layers: tuple[str, ...] = ()
+ missing_layers: tuple[str, ...] = ()
+ advertised_layer_count: int = 0
+ metadata_url: str | None = None
+ metadata_identifier: str | None = None
+ remote_title: str | None = None
+ remote_version: str | None = None
+ remote_modified_at: datetime | None = None
+ remote_published_at: datetime | None = None
+ capabilities_sha256: str | None = None
+ capabilities_etag: str | None = None
+ capabilities_last_modified_at: datetime | None = None
+ message: str = ""
+ error_code: str | None = None
+ cached: bool = False
+
+
+@dataclass(frozen=True)
+class CacheEntry:
+ expires_at: datetime
+ probe: RemoteProbe
+
+
+_REMOTE_CACHE: dict[str, CacheEntry] = {}
+_CACHE_LOCK = Lock()
+
+
+def _utc(value: datetime | None = None) -> datetime:
+ current = value or datetime.now(timezone.utc)
+ if current.tzinfo is None:
+ return current.replace(tzinfo=timezone.utc)
+ return current.astimezone(timezone.utc)
+
+
+def _header(headers: Any, name: str) -> str | None:
+ value = headers.get(name) if headers is not None else None
+ return str(value).strip() if value is not None and str(value).strip() else None
+
+
+def _http_date(value: str | None) -> datetime | None:
+ if not value:
+ return None
+ try:
+ return _utc(parsedate_to_datetime(value))
+ except (TypeError, ValueError, OverflowError):
+ return None
+
+
+def _with_capabilities_query(base_url: str, service_type: str) -> str:
+ parsed = urlsplit(base_url)
+ retained = [(key, value) for key, value in parse_qsl(parsed.query) if key.lower() not in {"service", "request", "version"}]
+ version = "2.0.0" if service_type == "WFS" else "1.3.0"
+ query = urlencode([*retained, ("SERVICE", service_type), ("VERSION", version), ("REQUEST", "GetCapabilities")])
+ return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, query, ""))
+
+
+def _bounded_fetch(url: str, settings: Settings, opener: Callable[..., Any] | None = None) -> FetchResult:
+ request = Request(
+ url,
+ headers={"Accept": "application/xml,text/xml,application/json", "User-Agent": "GeoIntel/0.1 source-catalog-probe"},
+ )
+ max_bytes = settings.source_catalog_probe_max_response_mb * 1024 * 1024
+ try:
+ with (opener or urlopen)(request, timeout=settings.source_catalog_probe_timeout_seconds) as response:
+ content_length = _header(response.headers, "Content-Length")
+ if content_length:
+ try:
+ if int(content_length) > max_bytes:
+ raise CatalogProbeFailure("CATALOG_RESPONSE_TOO_LARGE", "De officiële metadatarespons overschrijdt de ingestelde limiet.")
+ except ValueError as exc:
+ raise CatalogProbeFailure("CATALOG_INVALID_RESPONSE", "De officiële metadatarespons bevat een ongeldige Content-Length.") from exc
+ content = response.read(max_bytes + 1)
+ result = FetchResult(
+ content=content,
+ content_type=_header(response.headers, "Content-Type") or "",
+ etag=_header(response.headers, "ETag"),
+ last_modified_at=_http_date(_header(response.headers, "Last-Modified")),
+ final_url=str(response.geturl()) if hasattr(response, "geturl") else url,
+ )
+ except CatalogProbeFailure:
+ raise
+ except (HTTPError, URLError, TimeoutError, OSError) as exc:
+ raise CatalogProbeFailure("CATALOG_PROVIDER_UNAVAILABLE", "De officiële catalogus kon niet tijdig worden gelezen.") from exc
+ if len(result.content) > max_bytes:
+ raise CatalogProbeFailure("CATALOG_RESPONSE_TOO_LARGE", "De officiële metadatarespons overschrijdt de ingestelde limiet.")
+ return result
+
+
+def _local_name(tag: str) -> str:
+ return tag.rsplit("}", 1)[-1]
+
+
+def _direct_text(parent: ElementTree.Element, name: str) -> str | None:
+ for child in parent:
+ if _local_name(child.tag) == name and child.text and child.text.strip():
+ return child.text.strip()
+ return None
+
+
+def _metadata_urls(parent: ElementTree.Element) -> list[str]:
+ urls: list[str] = []
+ for node in parent.iter():
+ if _local_name(node.tag) not in {"MetadataURL", "MetadataUrl"}:
+ continue
+ href = node.attrib.get(f"{{{_XLINK}}}href")
+ if href:
+ urls.append(href.strip())
+ for child in node.iter():
+ if _local_name(child.tag) in {"URL", "OnlineResource"}:
+ nested = child.attrib.get(f"{{{_XLINK}}}href") or (child.text or "").strip()
+ if nested:
+ urls.append(nested)
+ return urls
+
+
+def _parse_capabilities(content: bytes, contract: ProbeContract) -> tuple[list[str], str | None]:
+ try:
+ root = ElementTree.fromstring(content)
+ except ElementTree.ParseError as exc:
+ raise CatalogProbeFailure("CATALOG_INVALID_XML", "De capabilities-respons is geen geldige XML.") from exc
+
+ layers: list[str] = []
+ metadata_urls: list[str] = []
+ node_name = "FeatureType" if contract.service_type == "WFS" else "Layer"
+ for node in root.iter():
+ if _local_name(node.tag) != node_name:
+ continue
+ raw_name = _direct_text(node, "Name")
+ if not raw_name:
+ continue
+ name = raw_name.rsplit(":", 1)[-1]
+ layers.append(name)
+ if name in contract.expected_layers:
+ metadata_urls.extend(_metadata_urls(node))
+
+ expected = set(contract.expected_layers)
+ if not expected.intersection(layers):
+ raise CatalogProbeFailure("CATALOG_EXPECTED_LAYERS_MISSING", "De officiële service bevat geen van de verwachte lagen.")
+ xml_metadata = next((url for url in metadata_urls if "GetRecordById" in url and "OUTPUTSCHEMA" in url.upper()), None)
+ return sorted(set(layers)), xml_metadata
+
+
+def _validate_metadata_url(url: str) -> str:
+ parsed = urlsplit(url)
+ if parsed.scheme != "https" or parsed.hostname != _METADATA_HOST or parsed.username or parsed.password:
+ raise CatalogProbeFailure("CATALOG_METADATA_URL_REJECTED", "De capabilities verwijzen niet naar de toegestane officiële metadatahost.")
+ if not parsed.path.startswith("/srv/dut/csw"):
+ raise CatalogProbeFailure("CATALOG_METADATA_URL_REJECTED", "De capabilities verwijzen niet naar het toegestane CSW-pad.")
+ query = {key.lower(): value for key, value in parse_qsl(parsed.query)}
+ if query.get("request", "").lower() != "getrecordbyid" or not query.get("id"):
+ raise CatalogProbeFailure("CATALOG_METADATA_URL_REJECTED", "De capabilities bevatten geen begrensde GetRecordById-verwijzing.")
+ return url
+
+
+def _validate_capabilities_url(url: str) -> str:
+ parsed = urlsplit(url)
+ if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username or parsed.password:
+ raise CatalogProbeFailure("CATALOG_ENDPOINT_REJECTED", "De ingestelde capabilities-URL moet een geldige HTTP(S)-URL zonder credentials zijn.")
+ return url
+
+
+def _node_text(node: ElementTree.Element | None) -> str | None:
+ if node is None:
+ return None
+ for descendant in node.iter():
+ if descendant is not node and descendant.text and descendant.text.strip():
+ return descendant.text.strip()
+ return node.text.strip() if node.text and node.text.strip() else None
+
+
+def _parse_iso_datetime(value: str | None) -> datetime | None:
+ if not value:
+ return None
+ normalized = value.strip().replace("Z", "+00:00")
+ try:
+ return _utc(datetime.fromisoformat(normalized))
+ except ValueError:
+ try:
+ return datetime.strptime(normalized, "%Y-%m-%d").replace(tzinfo=timezone.utc)
+ except ValueError:
+ return None
+
+
+def _parse_metadata(content: bytes) -> dict[str, Any]:
+ try:
+ root = ElementTree.fromstring(content)
+ except ElementTree.ParseError as exc:
+ raise CatalogProbeFailure("CATALOG_INVALID_METADATA_XML", "Het officiële metadatarecord is geen geldige XML.") from exc
+ namespaces = {"gmd": _GMD, "gco": _GCO}
+ metadata = root.find(".//gmd:MD_Metadata", namespaces)
+ if metadata is None and _local_name(root.tag) == "MD_Metadata":
+ metadata = root
+ if metadata is None:
+ raise CatalogProbeFailure("CATALOG_METADATA_MISSING", "Het CSW-antwoord bevat geen ISO 19139 metadatarecord.")
+ citation = metadata.find(".//gmd:identificationInfo/*/gmd:citation/gmd:CI_Citation", namespaces)
+ title = _node_text(citation.find("gmd:title", namespaces) if citation is not None else None)
+ edition = _node_text(citation.find("gmd:edition", namespaces) if citation is not None else None)
+ identifier = _node_text(metadata.find("gmd:fileIdentifier", namespaces))
+ modified = _parse_iso_datetime(_node_text(metadata.find("gmd:dateStamp", namespaces)))
+ published = None
+ if citation is not None:
+ for date_node in citation.findall("gmd:date/gmd:CI_Date", namespaces):
+ date_type = date_node.find("gmd:dateType/gmd:CI_DateTypeCode", namespaces)
+ if date_type is not None and date_type.attrib.get("codeListValue") == "publication":
+ published = _parse_iso_datetime(_node_text(date_node.find("gmd:date", namespaces)))
+ break
+ if not title or not edition:
+ raise CatalogProbeFailure("CATALOG_VERSION_MISSING", "Het officiële metadatarecord bevat geen herkenbare titel en editie.")
+ return {
+ "identifier": identifier,
+ "title": title,
+ "version": edition,
+ "modified_at": modified,
+ "published_at": published,
+ }
+
+
+def _probe_remote(
+ contract: ProbeContract,
+ settings: Settings,
+ *,
+ opener: Callable[..., Any] | None,
+ now: datetime,
+) -> RemoteProbe:
+ try:
+ capabilities = _bounded_fetch(_validate_capabilities_url(contract.endpoint_url), settings, opener)
+ _validate_capabilities_url(capabilities.final_url)
+ content_type = capabilities.content_type.lower()
+ if content_type and "xml" not in content_type and "text" not in content_type:
+ raise CatalogProbeFailure("CATALOG_INVALID_CONTENT_TYPE", "De officiële capabilities-respons is geen XML.")
+ layers, metadata_url = _parse_capabilities(capabilities.content, contract)
+ matched = tuple(layer for layer in contract.expected_layers if layer in layers)
+ missing = tuple(layer for layer in contract.expected_layers if layer not in layers)
+ digest = sha256(capabilities.content).hexdigest()
+ if not metadata_url:
+ return RemoteProbe(
+ status="degraded",
+ reachable=True,
+ checked_at=now,
+ expected_layers=contract.expected_layers,
+ matched_layers=matched,
+ missing_layers=missing,
+ advertised_layer_count=len(layers),
+ capabilities_sha256=digest,
+ capabilities_etag=capabilities.etag,
+ capabilities_last_modified_at=capabilities.last_modified_at,
+ message="De service is bereikbaar, maar publiceert geen machineleesbare ISO-metadata voor de verwachte lagen.",
+ error_code="CATALOG_METADATA_LINK_MISSING",
+ )
+ metadata_url = _validate_metadata_url(metadata_url)
+ metadata_response = _bounded_fetch(metadata_url, settings, opener)
+ _validate_metadata_url(metadata_response.final_url)
+ metadata = _parse_metadata(metadata_response.content)
+ status = "degraded" if missing else "available"
+ message = (
+ f"De officiële catalogus is bereikbaar en publiceert editie {metadata['version']}."
+ if not missing
+ else f"Editie {metadata['version']} is gevonden, maar niet alle verwachte lagen worden aangeboden."
+ )
+ return RemoteProbe(
+ status=status,
+ reachable=True,
+ checked_at=now,
+ expected_layers=contract.expected_layers,
+ matched_layers=matched,
+ missing_layers=missing,
+ advertised_layer_count=len(layers),
+ metadata_url=metadata_url,
+ metadata_identifier=metadata["identifier"],
+ remote_title=metadata["title"],
+ remote_version=metadata["version"],
+ remote_modified_at=metadata["modified_at"],
+ remote_published_at=metadata["published_at"],
+ capabilities_sha256=digest,
+ capabilities_etag=capabilities.etag,
+ capabilities_last_modified_at=capabilities.last_modified_at,
+ message=message,
+ error_code="CATALOG_EXPECTED_LAYERS_INCOMPLETE" if missing else None,
+ )
+ except CatalogProbeFailure as exc:
+ return RemoteProbe(
+ status="unavailable",
+ reachable=False,
+ checked_at=now,
+ expected_layers=contract.expected_layers,
+ missing_layers=contract.expected_layers,
+ message=exc.message,
+ error_code=exc.code,
+ )
+
+
+def _remote_with_cache(
+ contract: ProbeContract,
+ settings: Settings,
+ *,
+ force: bool,
+ opener: Callable[..., Any] | None,
+ now: datetime,
+) -> RemoteProbe:
+ cache_key = f"{contract.source_name}|{contract.endpoint_url}|{','.join(contract.expected_layers)}"
+ if not force and settings.source_catalog_probe_cache_ttl_seconds > 0:
+ with _CACHE_LOCK:
+ entry = _REMOTE_CACHE.get(cache_key)
+ if entry and entry.expires_at > now:
+ return replace(entry.probe, cached=True)
+ probe = _probe_remote(contract, settings, opener=opener, now=now)
+ if settings.source_catalog_probe_cache_ttl_seconds > 0:
+ with _CACHE_LOCK:
+ _REMOTE_CACHE[cache_key] = CacheEntry(
+ expires_at=now + timedelta(seconds=settings.source_catalog_probe_cache_ttl_seconds),
+ probe=probe,
+ )
+ return probe
+
+
+def _dataset_source_name(dataset: Dataset) -> str:
+ return (dataset.source_name or dataset.source or "").strip().lower()
+
+
+def _latest_local_version(source_name: str, datasets: list[Dataset]) -> str | None:
+ candidates = [item for item in datasets if _dataset_source_name(item) == source_name and item.source_version]
+ if source_name == "digitaal_vlaanderen_orthophoto":
+ explicit_current = [
+ item
+ for item in candidates
+ if any(token in (item.source_version or "").lower() for token in ("most_recent", "latest", "current"))
+ ]
+ if explicit_current:
+ candidates = explicit_current
+ if not candidates:
+ return None
+ latest = max(
+ candidates,
+ key=lambda item: (
+ _utc(item.imported_at) if item.imported_at else datetime.min.replace(tzinfo=timezone.utc),
+ _utc(item.observed_at) if item.observed_at else datetime.min.replace(tzinfo=timezone.utc),
+ str(item.id),
+ ),
+ )
+ return latest.source_version
+
+
+def _normalized_version(source_name: str, version: str | None) -> str | None:
+ if not version:
+ return None
+ value = version.strip()
+ if source_name == "grb":
+ match = _VERSION_DATE.fullmatch(value)
+ return match.group(1) if match else None
+ if source_name == "digitaal_vlaanderen_orthophoto" and _ORTHOPHOTO_EDITION.fullmatch(value):
+ return value
+ return None
+
+
+def _comparison(source_name: str, local: str | None, remote: str | None, remote_status: str) -> str:
+ if remote_status in {"unavailable", "disabled"}:
+ return "unavailable"
+ if not local:
+ return "no_local_data"
+ normalized_local = _normalized_version(source_name, local)
+ normalized_remote = _normalized_version(source_name, remote)
+ if normalized_local is None or normalized_remote is None:
+ return "not_comparable"
+ return "same" if normalized_local == normalized_remote else "different"
+
+
+class SourceCatalogProbeService:
+ @staticmethod
+ def clear_cache() -> None:
+ with _CACHE_LOCK:
+ _REMOTE_CACHE.clear()
+
+ @staticmethod
+ def audit_project(
+ db: Session,
+ project_id: UUID,
+ *,
+ force: bool = False,
+ opener: Callable[..., Any] | None = None,
+ now: datetime | None = None,
+ settings: Settings | None = None,
+ ) -> SourceCatalogProbeReport:
+ if not db.get(Project, project_id):
+ raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404)
+ active_settings = settings or get_settings()
+ generated_at = _utc(now)
+ datasets = db.query(Dataset).filter(Dataset.project_id == project_id).all()
+ contracts = (
+ ProbeContract(
+ source_name="grb",
+ display_name="Basiskaart Vlaanderen (GRB)",
+ service_type="WFS",
+ endpoint_url=_with_capabilities_query(active_settings.source_catalog_grb_wfs_url, "WFS"),
+ expected_layers=("GBG", "WBN", "WGO", "ADP"),
+ ),
+ ProbeContract(
+ source_name="digitaal_vlaanderen_orthophoto",
+ display_name="Orthofoto Vlaanderen",
+ service_type="WMS",
+ endpoint_url=_with_capabilities_query(active_settings.orthophoto_wms_url, "WMS"),
+ expected_layers=(active_settings.orthophoto_wms_layer, "Vliegdagcontour"),
+ ),
+ )
+ items: list[SourceCatalogProbeItem] = []
+ for contract in contracts:
+ local_version = _latest_local_version(contract.source_name, datasets)
+ if active_settings.source_catalog_probe_enabled:
+ remote = _remote_with_cache(
+ contract,
+ active_settings,
+ force=force,
+ opener=opener,
+ now=generated_at,
+ )
+ else:
+ remote = RemoteProbe(
+ status="disabled",
+ reachable=False,
+ checked_at=generated_at,
+ expected_layers=contract.expected_layers,
+ missing_layers=contract.expected_layers,
+ message="Officiële catalogusprobes zijn uitgeschakeld in de runtimeconfiguratie.",
+ error_code="CATALOG_PROBE_DISABLED",
+ )
+ comparison = _comparison(contract.source_name, local_version, remote.remote_version, remote.status)
+ message = remote.message
+ if comparison == "different":
+ message += " De officiële editie verschilt van de lokaal vastgelegde bronversie; controleer dit handmatig vóór een begrensde verversing."
+ elif comparison == "not_comparable" and local_version:
+ message += " De lokale waarde is een opname- of importmarkering en kan niet eerlijk als officiële cataloguseditie worden vergeleken."
+ items.append(
+ SourceCatalogProbeItem(
+ source_name=contract.source_name,
+ display_name=contract.display_name,
+ service_type=contract.service_type,
+ endpoint_url=contract.endpoint_url,
+ status=remote.status,
+ reachable=remote.reachable,
+ checked_at=remote.checked_at,
+ cached=remote.cached,
+ expected_layers=list(remote.expected_layers),
+ matched_layers=list(remote.matched_layers),
+ missing_layers=list(remote.missing_layers),
+ advertised_layer_count=remote.advertised_layer_count,
+ metadata_url=remote.metadata_url,
+ metadata_identifier=remote.metadata_identifier,
+ remote_title=remote.remote_title,
+ remote_version=remote.remote_version,
+ remote_modified_at=remote.remote_modified_at,
+ remote_published_at=remote.remote_published_at,
+ local_source_version=local_version,
+ comparison_status=comparison,
+ capabilities_sha256=remote.capabilities_sha256,
+ capabilities_etag=remote.capabilities_etag,
+ capabilities_last_modified_at=remote.capabilities_last_modified_at,
+ message=message,
+ error_code=remote.error_code,
+ )
+ )
+ summary = SourceCatalogProbeSummary(
+ provider_count=len(items),
+ available_count=sum(item.status == "available" for item in items),
+ degraded_count=sum(item.status == "degraded" for item in items),
+ unavailable_count=sum(item.status == "unavailable" for item in items),
+ disabled_count=sum(item.status == "disabled" for item in items),
+ different_version_count=sum(item.comparison_status == "different" for item in items),
+ )
+ return SourceCatalogProbeReport(
+ project_id=project_id,
+ generated_at=generated_at,
+ summary=summary,
+ items=items,
+ limitations=[
+ "Deze expliciete controle leest alleen allowlisted WFS/WMS-capabilities en gekoppelde ISO 19139 metadata.",
+ "Er worden geen features, rasters of modelbestanden opgehaald en geen datasets aangemaakt of overschreven.",
+ "Een versieverschil is controlesignaal, geen bewijs dat een lokale dataset onbruikbaar is en geen automatische importopdracht.",
+ ],
+ )
diff --git a/backend/tests/test_sprint222_source_catalog_probes.py b/backend/tests/test_sprint222_source_catalog_probes.py
new file mode 100644
index 00000000..23451d95
--- /dev/null
+++ b/backend/tests/test_sprint222_source_catalog_probes.py
@@ -0,0 +1,332 @@
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from email.message import Message
+from pathlib import Path
+from types import SimpleNamespace
+from urllib.error import URLError
+import uuid
+
+from app.core.config import Settings
+from app.models import Dataset
+from app.services.source_catalog_probe_service import SourceCatalogProbeService
+
+
+NOW = datetime(2026, 7, 16, 15, 0, tzinfo=timezone.utc)
+GRB_METADATA_URL = (
+ "https://metadata.vlaanderen.be/srv/dut/csw?request=GetRecordById&service=CSW&"
+ "id=7C823055-7BBF-4D62-B55E-F85C30D53162&OUTPUTSCHEMA=http://www.isotc211.org/2005/gmd"
+)
+ORTHO_METADATA_URL = (
+ "https://metadata.vlaanderen.be/srv/dut/csw?request=GetRecordById&service=CSW&"
+ "id=f5304d6d-0dd4-43fd-a726-427af31e8d61&OUTPUTSCHEMA=http://www.isotc211.org/2005/gmd"
+)
+
+
+def _wfs_capabilities(metadata_url: str = GRB_METADATA_URL) -> bytes:
+ layers = "".join(
+ f"""
+ {item.message} Bronbeheer Controleert lokale publicaties, versies en bestanden zonder externe bronnen automatisch te wijzigen. Controleert lokale publicaties en kan op aanvraag de officiële GRB- en orthofoto-editie uitlezen. {error} {report.limitations.join(' ')}
diff --git a/frontend/src/components/status/SourceFreshnessPanel.tsx b/frontend/src/components/status/SourceFreshnessPanel.tsx
index 8fec2012..2e089ded 100644
--- a/frontend/src/components/status/SourceFreshnessPanel.tsx
+++ b/frontend/src/components/status/SourceFreshnessPanel.tsx
@@ -1,10 +1,22 @@
-import type { SourceFreshnessItem, SourceFreshnessReport, SourceFreshnessStatus } from '../../types'
+import type {
+ SourceCatalogComparisonStatus,
+ SourceCatalogProbeItem,
+ SourceCatalogProbeReport,
+ SourceCatalogProbeStatus,
+ SourceFreshnessItem,
+ SourceFreshnessReport,
+ SourceFreshnessStatus,
+} from '../../types'
interface SourceFreshnessPanelProps {
report: SourceFreshnessReport | null
loading: boolean
error: string | null
onRefresh: () => void
+ catalogReport: SourceCatalogProbeReport | null
+ catalogLoading: boolean
+ catalogError: string | null
+ onProbeCatalogs: (force: boolean) => void
}
function statusLabel(status: SourceFreshnessStatus): string {
@@ -14,6 +26,21 @@ function statusLabel(status: SourceFreshnessStatus): string {
return 'lokaal'
}
+function catalogStatusLabel(status: SourceCatalogProbeStatus): string {
+ if (status === 'available') return 'bereikbaar'
+ if (status === 'degraded') return 'beperkt'
+ if (status === 'unavailable') return 'niet bereikbaar'
+ return 'uitgeschakeld'
+}
+
+function comparisonLabel(status: SourceCatalogComparisonStatus): string {
+ if (status === 'same') return 'zelfde editie'
+ if (status === 'different') return 'verschil controleren'
+ if (status === 'no_local_data') return 'nog niet lokaal ingeladen'
+ if (status === 'not_comparable') return 'andere versienotatie'
+ return 'niet vergelijkbaar'
+}
+
function formatDate(value?: string | null): string {
if (!value) return 'geen datum'
return new Intl.DateTimeFormat('nl-BE', { dateStyle: 'medium' }).format(new Date(value))
@@ -23,6 +50,27 @@ function integrityIssueCount(item: SourceFreshnessItem): number {
return Object.values(item.integrity).reduce((total, value) => total + value, 0)
}
+function CatalogRow({ item }: { item: SourceCatalogProbeItem }): JSX.Element {
+ return (
+
Actualiteit en versiecontrole
-
{catalogError}
: null} + {!catalogReport && !catalogError ? ( +Controleer GRB en orthofoto wanneer je een lokale bronversie met de officiële editie wilt vergelijken.
+ ) : null} + {catalogReport ? ( + <> ++ Gecontroleerd {formatDate(catalogReport.generated_at)}. Een verschil vraagt menselijke beoordeling en start nooit automatisch een import. +
+ > + ) : null} +