feat: make spatial entry optional and authoritative
GeoIntel release gates / Compile, test, contracts and builds (push) Canceled after 0s
GeoIntel release gates / Python and npm vulnerability policy (push) Canceled after 0s
GeoIntel release gates / GIS image, SBOM and container scan (push) Canceled after 0s

This commit is contained in:
Jens
2026-07-26 03:39:50 +02:00
parent 5efcf94d82
commit 25b6f1ab39
21 changed files with 462 additions and 104 deletions
+18 -1
View File
@@ -9,7 +9,7 @@ from sqlalchemy.orm import Session
from app.db.session import get_db
from app.models import Area
from app.schemas import Envelope
from app.schemas.area import AreaCreate, AreaList, AreaRead, AreaUpdate
from app.schemas.area import AreaCreate, AreaList, AreaRead, AreaUpdate, MunicipalitySearchList
from app.services.area_service import AreaService
from app.utils.response import envelope
@@ -33,6 +33,23 @@ def create_area(project_id: UUID, payload: AreaCreate, db: Session = Depends(get
return envelope(AreaService.serialize_area(area))
@router.get("/municipalities", response_model=Envelope[MunicipalitySearchList])
def search_municipalities(
project_id: UUID,
query: str = Query(default="", max_length=120),
limit: int = Query(default=20, ge=1, le=50),
db: Session = Depends(get_db),
):
items, total = AreaService.search_municipalities(db, project_id, query, limit)
return envelope({"items": items, "total": total})
@router.post("/municipalities/{niscode}/activate", response_model=Envelope[AreaRead])
def activate_municipality(project_id: UUID, niscode: str, db: Session = Depends(get_db)):
area = AreaService.activate_municipality(db, project_id, niscode)
return envelope(AreaService.serialize_area(area))
@router.get("/{area_id}", response_model=Envelope[AreaRead])
def get_area(
project_id: UUID,
+13
View File
@@ -39,3 +39,16 @@ class AreaList(BaseModel):
total: int
limit: int
offset: int
class MunicipalitySearchItem(BaseModel):
niscode: str
name: str
name_nl: str | None = None
name_fr: str | None = None
name_de: str | None = None
class MunicipalitySearchList(BaseModel):
items: list[MunicipalitySearchItem]
total: int
+79 -1
View File
@@ -7,12 +7,90 @@ from geoalchemy2.shape import from_shape, to_shape
from shapely.geometry import mapping
from app.core.errors import AppError
from app.models import Area, Project
from app.models import Area, Dataset, Project, VectorFeature
from app.schemas.area import AreaCreate, AreaRead, AreaUpdate
from app.utils.geometry import area_m2, geometry_bbox_polygon, normalize_to_multipolygon
class AreaService:
@staticmethod
def _municipality_dataset(db: Session, project_id: uuid.UUID) -> Dataset | None:
return (
db.query(Dataset)
.filter(
Dataset.project_id == project_id,
Dataset.reference_layer_name == "belgium_municipalities",
Dataset.status == "ready",
)
.order_by(Dataset.created_at.desc())
.first()
)
@staticmethod
def _filter_municipality_properties(properties_items: list[dict], query: str, limit: int) -> tuple[list[dict], int]:
normalized = query.strip().casefold()
matches: list[dict] = []
for properties in properties_items:
names = [str(properties.get(key) or "").strip() for key in ("namedut", "namefre", "nameger")]
niscode = str(properties.get("niscode") or "").strip()
if normalized and normalized not in " ".join([niscode, *names]).casefold():
continue
display_name = next((name for name in names if name), niscode)
matches.append({
"niscode": niscode,
"name": display_name,
"name_nl": names[0] or None,
"name_fr": names[1] or None,
"name_de": names[2] or None,
})
matches.sort(key=lambda item: (item["name"].casefold(), item["niscode"]))
return matches[:limit], len(matches)
@staticmethod
def search_municipalities(db: Session, project_id: uuid.UUID, query: str, limit: int = 20) -> tuple[list[dict], int]:
dataset = AreaService._municipality_dataset(db, project_id)
if dataset is None:
return [], 0
property_rows = (
db.query(VectorFeature.properties_json)
.filter(VectorFeature.dataset_id == dataset.id)
.all()
)
properties_items = [row[0] for row in property_rows if isinstance(row[0], dict)]
return AreaService._filter_municipality_properties(properties_items, query, limit)
@staticmethod
def activate_municipality(db: Session, project_id: uuid.UUID, niscode: str) -> Area:
normalized_code = niscode.strip()
dataset = AreaService._municipality_dataset(db, project_id)
if dataset is None:
raise AppError(code="MUNICIPALITY_NOT_FOUND", message="Municipality is not available in the official NGI administrative layer", status_code=404)
feature = (
db.query(VectorFeature)
.filter(
VectorFeature.dataset_id == dataset.id,
VectorFeature.properties_json["niscode"].as_string() == normalized_code,
)
.first()
)
if feature is not None:
properties = feature.properties_json if isinstance(feature.properties_json, dict) else {}
display_name = next(
(str(properties.get(key) or "").strip() for key in ("namedut", "namefre", "nameger") if str(properties.get(key) or "").strip()),
normalized_code,
)
area_name = f"Gemeente {display_name} - NIS {normalized_code}"
existing = db.query(Area).filter(Area.project_id == project_id, Area.name == area_name).first()
if existing is not None:
return existing
geometry = to_shape(feature.geometry)
return AreaService.create_area(
db,
project_id,
AreaCreate(name=area_name, geometry=mapping(geometry), crs="EPSG:4326"),
)
raise AppError(code="MUNICIPALITY_NOT_FOUND", message="Municipality is not available in the official NGI administrative layer", status_code=404)
@staticmethod
def serialize_area(area: Area) -> dict:
geometry = to_shape(area.geometry) if area.geometry else None
@@ -0,0 +1,81 @@
from __future__ import annotations
from datetime import datetime, timezone
from types import SimpleNamespace
from uuid import uuid4
from fastapi.testclient import TestClient
from app.db.session import get_db
from app.main import create_app
from app.services.area_service import AreaService
def test_municipality_service_matches_all_official_names_and_nis(monkeypatch):
records = [
{"niscode": "63004", "namedut": "Baelen", "namefre": "Baelen", "nameger": "Balen"},
{"niscode": "13025", "namedut": "Mol", "namefre": "Mol", "nameger": "Mol"},
]
by_german_name, name_total = AreaService._filter_municipality_properties(records, "Balen", 20)
by_nis, nis_total = AreaService._filter_municipality_properties(records, "13025", 20)
assert name_total == 1
assert by_german_name[0]["niscode"] == "63004"
assert nis_total == 1
assert by_nis[0]["name"] == "Mol"
def test_municipality_search_uses_authoritative_catalog(monkeypatch):
project_id = uuid4()
monkeypatch.setattr(
AreaService,
"search_municipalities",
staticmethod(lambda _db, requested_project_id, query, limit: (
[{"niscode": "13025", "name": "Mol", "name_nl": "Mol", "name_fr": "Mol", "name_de": "Mol"}],
1,
) if requested_project_id == project_id and query == "mol" and limit == 12 else ([], 0)),
)
app = create_app()
app.dependency_overrides[get_db] = lambda: object()
try:
response = TestClient(app).get(f"/api/v1/projects/{project_id}/areas/municipalities?query=mol&limit=12")
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
assert response.json()["data"] == {
"items": [{"niscode": "13025", "name": "Mol", "name_nl": "Mol", "name_fr": "Mol", "name_de": "Mol"}],
"total": 1,
}
def test_municipality_activation_returns_persisted_area(monkeypatch):
project_id = uuid4()
area_id = uuid4()
area = SimpleNamespace(id=area_id, project_id=project_id)
monkeypatch.setattr(AreaService, "activate_municipality", staticmethod(lambda _db, requested_project_id, niscode: area))
monkeypatch.setattr(
AreaService,
"serialize_area",
staticmethod(lambda _area: {
"id": area_id,
"project_id": project_id,
"name": "Gemeente Mol - NIS 13025",
"original_crs": "EPSG:4326",
"area_m2": 114000000.0,
"created_at": datetime(2026, 7, 26, tzinfo=timezone.utc),
"geometry_type": "MultiPolygon",
"geometry": {"type": "MultiPolygon", "coordinates": []},
}),
)
app = create_app()
app.dependency_overrides[get_db] = lambda: object()
try:
response = TestClient(app).post(f"/api/v1/projects/{project_id}/areas/municipalities/13025/activate", json={})
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
assert response.json()["data"]["id"] == str(area_id)
assert response.json()["data"]["name"] == "Gemeente Mol - NIS 13025"