Add Belgium and North Sea coverage foundation
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from shapely.geometry import box
|
||||
|
||||
from app.core.errors import AppError
|
||||
from app.main import app
|
||||
from app.models import Area, Dataset, Project
|
||||
from app.schemas.coverage import CoverageBBox
|
||||
from app.services.coverage_registry_service import CoverageRegistryService, THEMES, ZONES
|
||||
|
||||
|
||||
class FakeQuery:
|
||||
def __init__(self, rows):
|
||||
self.rows = rows
|
||||
|
||||
def filter(self, *_args, **_kwargs):
|
||||
return self
|
||||
|
||||
def all(self):
|
||||
return list(self.rows)
|
||||
|
||||
|
||||
class FakeSession:
|
||||
def __init__(self, *, project, areas, datasets):
|
||||
self.project = project
|
||||
self.areas = areas
|
||||
self.datasets = datasets
|
||||
|
||||
def get(self, model, object_id):
|
||||
if model is Project and str(self.project.id) == str(object_id):
|
||||
return self.project
|
||||
return None
|
||||
|
||||
def query(self, model):
|
||||
if model is Area:
|
||||
return FakeQuery(self.areas)
|
||||
if model is Dataset:
|
||||
return FakeQuery(self.datasets)
|
||||
raise AssertionError(f"Unexpected query model: {model}")
|
||||
|
||||
|
||||
def scope_area(name: str, geometry):
|
||||
return SimpleNamespace(name=name, geometry=geometry)
|
||||
|
||||
|
||||
def test_coverage_catalog_uses_normalized_contracts_and_does_not_change_provider_registry() -> None:
|
||||
catalog = CoverageRegistryService.catalog()
|
||||
|
||||
assert set(catalog.themes) == set(THEMES)
|
||||
assert set(catalog.zones) == set(ZONES)
|
||||
assert catalog.statuses == ["unsupported", "not_configured", "partial", "operational"]
|
||||
assert {source.source_name for source in catalog.sources} >= {
|
||||
"ngi_adminvector",
|
||||
"statbel",
|
||||
"digitaal_vlaanderen",
|
||||
"spw_geoportail",
|
||||
"urbis",
|
||||
"rbins_marine_reporting_units",
|
||||
"rbins_msp_2026",
|
||||
"mdk_bathymetry",
|
||||
}
|
||||
assert next(source for source in catalog.sources if source.source_name == "ngi_adminvector").license_note == "CC BY 4.0"
|
||||
assert next(source for source in catalog.sources if source.source_name == "mdk_bathymetry").integration_status == "not_configured"
|
||||
|
||||
response = TestClient(app).get("/api/v1/external/coverage/catalog")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["data"]["themes"] == list(THEMES)
|
||||
|
||||
|
||||
def test_coverage_resolver_only_reports_operational_for_materialized_ready_dataset() -> None:
|
||||
project_id = uuid4()
|
||||
project = SimpleNamespace(id=project_id)
|
||||
areas = [
|
||||
scope_area("Belgium land", box(2.5, 49.5, 6.4, 51.5)),
|
||||
scope_area("Flanders", box(2.5, 50.7, 5.9, 51.5)),
|
||||
]
|
||||
bbox = CoverageBBox(minx=4.9, miny=50.9, maxx=5.0, maxy=51.0)
|
||||
|
||||
without_materialized = CoverageRegistryService.resolve(
|
||||
FakeSession(project=project, areas=areas, datasets=[]),
|
||||
project_id,
|
||||
bbox,
|
||||
["admin"],
|
||||
)
|
||||
assert without_materialized.intersected_zones == ["flanders"]
|
||||
assert without_materialized.items[0].status == "partial"
|
||||
assert without_materialized.items[0].materialized_dataset_ids == []
|
||||
|
||||
dataset_id = uuid4()
|
||||
materialized = SimpleNamespace(
|
||||
id=dataset_id,
|
||||
status="ready",
|
||||
source_name="ngi_adminvector",
|
||||
reference_layer_name="belgium_regions",
|
||||
source_metadata={"coverage_zones": ["belgium", "flanders", "wallonia", "brussels"]},
|
||||
)
|
||||
with_materialized = CoverageRegistryService.resolve(
|
||||
FakeSession(project=project, areas=areas, datasets=[materialized]),
|
||||
project_id,
|
||||
bbox,
|
||||
["admin"],
|
||||
)
|
||||
admin_item = next(item for item in with_materialized.items if item.zone == "flanders")
|
||||
assert admin_item.status == "operational"
|
||||
assert admin_item.materialized_dataset_ids == [dataset_id]
|
||||
|
||||
|
||||
def test_mixed_land_and_north_sea_selection_remains_split() -> None:
|
||||
project_id = uuid4()
|
||||
project = SimpleNamespace(id=project_id)
|
||||
areas = [
|
||||
scope_area("Belgium land", box(2.5, 49.5, 6.4, 51.5)),
|
||||
scope_area("Flanders", box(2.5, 50.7, 5.9, 51.5)),
|
||||
scope_area("Belgian part of the North Sea", box(2.2, 51.1, 3.4, 51.9)),
|
||||
scope_area("Belgian territorial sea (0-12 nautical miles)", box(2.7, 51.1, 3.4, 51.5)),
|
||||
scope_area("Belgian exclusive economic zone beyond territorial sea", box(2.2, 51.5, 3.1, 51.9)),
|
||||
scope_area("Belgian continental shelf beyond territorial sea", box(2.2, 51.5, 3.1, 51.9)),
|
||||
]
|
||||
|
||||
result = CoverageRegistryService.resolve(
|
||||
FakeSession(project=project, areas=areas, datasets=[]),
|
||||
project_id,
|
||||
CoverageBBox(minx=2.65, miny=51.05, maxx=2.85, maxy=51.2),
|
||||
["admin", "bathymetry"],
|
||||
)
|
||||
|
||||
assert result.intersected_zones == ["flanders", "territorial_sea"]
|
||||
assert len(result.items) == 4
|
||||
assert any("crosses coverage zones" in warning for warning in result.warnings)
|
||||
bathymetry = next(item for item in result.items if item.zone == "territorial_sea" and item.theme == "bathymetry")
|
||||
assert bathymetry.status == "not_configured"
|
||||
assert bathymetry.materialized_dataset_ids == []
|
||||
|
||||
|
||||
def test_flemish_materialization_is_theme_specific() -> None:
|
||||
project_id = uuid4()
|
||||
project = SimpleNamespace(id=project_id)
|
||||
orthophoto_id = uuid4()
|
||||
orthophoto = SimpleNamespace(
|
||||
id=orthophoto_id,
|
||||
status="ready",
|
||||
source_name="digitaal_vlaanderen_orthophoto",
|
||||
reference_layer_name="orthophoto",
|
||||
source_metadata={"coverage_zones": ["flanders"]},
|
||||
)
|
||||
result = CoverageRegistryService.resolve(
|
||||
FakeSession(
|
||||
project=project,
|
||||
areas=[scope_area("Flanders", box(2.5, 50.7, 5.9, 51.5))],
|
||||
datasets=[orthophoto],
|
||||
),
|
||||
project_id,
|
||||
CoverageBBox(minx=4.9, miny=50.9, maxx=5.0, maxy=51.0),
|
||||
["orthophoto", "roads"],
|
||||
)
|
||||
|
||||
assert next(item for item in result.items if item.theme == "orthophoto").status == "operational"
|
||||
assert next(item for item in result.items if item.theme == "roads").status == "partial"
|
||||
|
||||
|
||||
def test_outside_scope_and_unknown_theme_are_explicit() -> None:
|
||||
project_id = uuid4()
|
||||
project = SimpleNamespace(id=project_id)
|
||||
db = FakeSession(
|
||||
project=project,
|
||||
areas=[scope_area("Belgium land", box(2.5, 49.5, 6.4, 51.5))],
|
||||
datasets=[],
|
||||
)
|
||||
result = CoverageRegistryService.resolve(
|
||||
db,
|
||||
project_id,
|
||||
CoverageBBox(minx=7.0, miny=52.0, maxx=7.1, maxy=52.1),
|
||||
["admin"],
|
||||
)
|
||||
assert result.intersected_zones == []
|
||||
assert result.outside_supported_scope is True
|
||||
assert result.items == []
|
||||
|
||||
try:
|
||||
CoverageRegistryService.resolve(
|
||||
db,
|
||||
project_id,
|
||||
CoverageBBox(minx=4.0, miny=50.0, maxx=4.1, maxy=50.1),
|
||||
["invented_metric"],
|
||||
)
|
||||
except AppError as exc:
|
||||
assert exc.code == "COVERAGE_THEME_UNSUPPORTED"
|
||||
assert exc.status_code == 422
|
||||
assert exc.details["unsupported_themes"] == ["invented_metric"]
|
||||
else:
|
||||
raise AssertionError("Unknown coverage theme was accepted")
|
||||
|
||||
|
||||
def test_frontend_prefers_materialized_national_workspace_and_resolves_drawn_bbox() -> None:
|
||||
root = Path(__file__).parents[2]
|
||||
focus = (root / "frontend" / "src" / "config" / "primaryFocus.ts").read_text(encoding="utf-8")
|
||||
workspace_hook = (root / "frontend" / "src" / "hooks" / "useProjectWorkspace.ts").read_text(encoding="utf-8")
|
||||
coverage_hook = (root / "frontend" / "src" / "hooks" / "useCoverageResolver.ts").read_text(encoding="utf-8")
|
||||
map_workspace = (root / "frontend" / "src" / "components" / "map" / "MapWorkspace.tsx").read_text(encoding="utf-8")
|
||||
|
||||
assert "Belgium and North Sea Workbench" in focus
|
||||
assert "nationalProject" in workspace_hook
|
||||
assert "data.areas.length > 0" in workspace_hook
|
||||
assert "dataset.status === 'ready'" in workspace_hook
|
||||
assert "externalApi.resolveCoverage" in coverage_hook
|
||||
assert "coverage.outside_supported_scope" in map_workspace
|
||||
assert "coverageStatusLabel" in map_workspace
|
||||
@@ -0,0 +1,161 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from shapely.geometry import box, mapping, shape
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[2]
|
||||
SCRIPTS = ROOT / "scripts"
|
||||
if str(SCRIPTS) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
import provision_belgium_north_sea_scope as operator # noqa: E402
|
||||
|
||||
|
||||
def marine_feature(identifier: str, geometry):
|
||||
return {
|
||||
"type": "Feature",
|
||||
"id": identifier,
|
||||
"geometry": mapping(geometry),
|
||||
"properties": {"MarineReportingUnitId": identifier},
|
||||
}
|
||||
|
||||
|
||||
def test_marine_legal_scopes_are_derived_from_official_reporting_units() -> None:
|
||||
reporting_units = {
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
marine_feature("ANS-BE-MS-1", box(0, 0, 10, 10)),
|
||||
marine_feature("ANS-BE-AA-CW", box(0, 0, 2, 2)),
|
||||
marine_feature("ANS-BE-AA-TEW", box(0, 2, 2, 4)),
|
||||
marine_feature("ANS-BE-AA-OFFSHORE", box(2, 0, 10, 10)),
|
||||
],
|
||||
}
|
||||
|
||||
payload = operator.derive_marine_scope_payload(reporting_units)
|
||||
by_zone = {
|
||||
feature["properties"]["coverage_zone"]: feature
|
||||
for feature in payload["features"]
|
||||
}
|
||||
|
||||
assert set(by_zone) == {
|
||||
"belgian_north_sea",
|
||||
"territorial_sea",
|
||||
"exclusive_economic_zone",
|
||||
"continental_shelf",
|
||||
}
|
||||
assert shape(by_zone["territorial_sea"]["geometry"]).area == pytest.approx(8.0)
|
||||
assert shape(by_zone["exclusive_economic_zone"]["geometry"]).equals(
|
||||
shape(by_zone["continental_shelf"]["geometry"])
|
||||
)
|
||||
assert (
|
||||
by_zone["exclusive_economic_zone"]["properties"]["legal_domain"]
|
||||
!= by_zone["continental_shelf"]["properties"]["legal_domain"]
|
||||
)
|
||||
assert by_zone["territorial_sea"]["properties"]["derived_from_reporting_unit_ids"] == [
|
||||
"ANS-BE-AA-CW",
|
||||
"ANS-BE-AA-TEW",
|
||||
]
|
||||
|
||||
|
||||
def test_marine_scope_derivation_fails_when_a_required_unit_is_missing() -> None:
|
||||
with pytest.raises(RuntimeError, match="ANS-BE-AA-CW"):
|
||||
operator.derive_marine_scope_payload(
|
||||
{
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
marine_feature("ANS-BE-MS-1", box(0, 0, 10, 10)),
|
||||
marine_feature("ANS-BE-AA-TEW", box(0, 2, 2, 4)),
|
||||
marine_feature("ANS-BE-AA-OFFSHORE", box(2, 0, 10, 10)),
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_archive_extraction_accepts_one_safe_geopackage_and_rejects_traversal(tmp_path: Path) -> None:
|
||||
archive = tmp_path / "adminvector.zip"
|
||||
with zipfile.ZipFile(archive, "w") as handle:
|
||||
handle.writestr("release/adminvector.gpkg", b"sqlite-bytes")
|
||||
|
||||
result = operator.extract_single_geopackage(archive, tmp_path / "output")
|
||||
assert result.read_bytes() == b"sqlite-bytes"
|
||||
|
||||
unsafe = tmp_path / "unsafe.zip"
|
||||
with zipfile.ZipFile(unsafe, "w") as handle:
|
||||
handle.writestr("../adminvector.gpkg", b"unsafe")
|
||||
with pytest.raises(RuntimeError, match="unsafe"):
|
||||
operator.extract_single_geopackage(unsafe, tmp_path / "unsafe-output")
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, payload):
|
||||
self.payload = payload
|
||||
self.content = json.dumps(payload).encode("utf-8")
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return self.payload
|
||||
|
||||
|
||||
class FakeSession:
|
||||
def __init__(self, pages):
|
||||
self.pages = list(pages)
|
||||
self.calls = []
|
||||
|
||||
def get(self, url, params, timeout):
|
||||
self.calls.append({"url": url, "params": params, "timeout": timeout})
|
||||
return FakeResponse(self.pages.pop(0))
|
||||
|
||||
|
||||
def test_wfs_fetch_is_allowlisted_paginated_and_complete() -> None:
|
||||
pages = [
|
||||
{
|
||||
"type": "FeatureCollection",
|
||||
"numberMatched": 2,
|
||||
"features": [{"type": "Feature", "id": "unit.1", "geometry": None, "properties": {}}],
|
||||
},
|
||||
{
|
||||
"type": "FeatureCollection",
|
||||
"numberMatched": 2,
|
||||
"features": [{"type": "Feature", "id": "unit.2", "geometry": None, "properties": {}}],
|
||||
},
|
||||
]
|
||||
session = FakeSession(pages)
|
||||
payload = operator.fetch_wfs_layer(
|
||||
session,
|
||||
service_url=operator.RBINS_MRU_WFS_URL,
|
||||
layer_name=operator.RBINS_MRU_LAYER,
|
||||
timeout=30,
|
||||
page_size=1,
|
||||
)
|
||||
|
||||
assert [feature["id"] for feature in payload["features"]] == ["unit.1", "unit.2"]
|
||||
assert [call["params"]["startIndex"] for call in session.calls] == [0, 1]
|
||||
assert all(call["params"]["srsName"] == "EPSG:4326" for call in session.calls)
|
||||
|
||||
with pytest.raises(RuntimeError, match="allowlist"):
|
||||
operator.fetch_wfs_layer(
|
||||
FakeSession([]),
|
||||
service_url=operator.RBINS_MSP_WFS_URL,
|
||||
layer_name="untrusted:layer",
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
|
||||
def test_operator_is_packaged_and_guarded_by_readiness() -> None:
|
||||
dockerfile = (ROOT / "deploy" / "unraid" / "Dockerfile.all-in-one").read_text(encoding="utf-8")
|
||||
readiness = (ROOT / "scripts" / "run_readiness_check.sh").read_text(encoding="utf-8")
|
||||
source = (ROOT / "scripts" / "provision_belgium_north_sea_scope.py").read_text(encoding="utf-8")
|
||||
|
||||
assert "COPY scripts/provision_belgium_north_sea_scope.py /app/scripts/" in dockerfile
|
||||
assert "py_compile scripts/provision_belgium_north_sea_scope.py" in readiness
|
||||
assert "/datasets/upload" in source
|
||||
assert "from app.models" not in source
|
||||
assert "INSERT INTO vector_features" not in source
|
||||
@@ -113,7 +113,10 @@ def test_tower_deploy_uses_single_container_unraid_compose() -> None:
|
||||
for script in (powershell, bash):
|
||||
assert "docker compose -f docker-compose.unraid.yml config" in script
|
||||
assert "--build-arg GEOINTEL_INSTALL_AI=" in script
|
||||
assert "-f deploy/unraid/Dockerfile.all-in-one -t geointel-all-in-one:latest ." in script
|
||||
assert '--build-arg GEOINTEL_BUILD_SHA="$GEOINTEL_BUILD_SHA"' in script
|
||||
assert '--build-arg GEOINTEL_BUILD_TIME="$GEOINTEL_BUILD_TIME"' in script
|
||||
assert "-f deploy/unraid/Dockerfile.all-in-one" in script
|
||||
assert "-t geointel-all-in-one:latest" in script
|
||||
assert "docker compose -f docker-compose.unraid.yml build geointel" not in script
|
||||
assert "bash deploy/unraid/run-dockerman-container.sh" in script
|
||||
assert "LIVE_SMOKE_CONTAINER=geointel bash scripts/live_migration_smoke.sh" in script
|
||||
|
||||
Reference in New Issue
Block a user