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
+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