feat: make spatial entry optional and authoritative
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user