Initial public release
GeoIntel release gates / Compile, test, contracts and builds (push) Successful in 1m49s
GeoIntel release gates / Python and npm vulnerability policy (push) Successful in 21s
GeoIntel release gates / Production AI image, SBOM and container scan (push) Successful in 5m39s
GeoIntel release gates / Deploy exact gated revision to Unraid (push) Failing after 58m43s
GeoIntel release gates / Compile, test, contracts and builds (push) Successful in 1m49s
GeoIntel release gates / Python and npm vulnerability policy (push) Successful in 21s
GeoIntel release gates / Production AI image, SBOM and container scan (push) Successful in 5m39s
GeoIntel release gates / Deploy exact gated revision to Unraid (push) Failing after 58m43s
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from geoalchemy2.shape import from_shape, to_shape
|
||||
from shapely.geometry import mapping
|
||||
|
||||
from app.core.errors import AppError
|
||||
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_area_to_epsg4326
|
||||
|
||||
|
||||
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
|
||||
return AreaRead.model_validate(
|
||||
{
|
||||
"id": area.id,
|
||||
"project_id": area.project_id,
|
||||
"name": area.name,
|
||||
"original_crs": area.original_crs,
|
||||
"area_m2": area.area_m2,
|
||||
"created_at": area.created_at,
|
||||
"geometry_type": geometry.geom_type if geometry else None,
|
||||
"geometry": mapping(geometry) if geometry else None,
|
||||
}
|
||||
).model_dump()
|
||||
|
||||
@staticmethod
|
||||
def list_areas(db: Session, project_id: uuid.UUID, limit: int = 50, offset: int = 0) -> tuple[list[Area], int]:
|
||||
total = db.query(Area).filter(Area.project_id == project_id).count()
|
||||
areas = (
|
||||
db.query(Area)
|
||||
.filter(Area.project_id == project_id)
|
||||
.order_by(Area.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return areas, total
|
||||
|
||||
@staticmethod
|
||||
def create_area(db: Session, project_id: uuid.UUID, payload: AreaCreate) -> Area:
|
||||
if not db.get(Project, project_id):
|
||||
raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404)
|
||||
|
||||
try:
|
||||
multipolygon, original_crs = normalize_area_to_epsg4326(
|
||||
payload.geometry,
|
||||
payload.crs or "EPSG:4326",
|
||||
)
|
||||
metric_area = area_m2(multipolygon)
|
||||
except ValueError as exc:
|
||||
raise AppError(code="INVALID_GEOMETRY", message=str(exc), status_code=400) from exc
|
||||
|
||||
area = Area(
|
||||
project_id=project_id,
|
||||
name=payload.name.strip() or "Unnamed area",
|
||||
geometry=from_shape(multipolygon, srid=4326),
|
||||
original_crs=original_crs,
|
||||
area_m2=metric_area,
|
||||
bbox=from_shape(geometry_bbox_polygon(multipolygon), srid=4326),
|
||||
)
|
||||
db.add(area)
|
||||
db.commit()
|
||||
db.refresh(area)
|
||||
return area
|
||||
|
||||
@staticmethod
|
||||
def get_area(db: Session, area_id: uuid.UUID) -> Area:
|
||||
area = db.get(Area, area_id)
|
||||
if not area:
|
||||
raise AppError(code="AREA_NOT_FOUND", message="Area not found", status_code=404)
|
||||
return area
|
||||
|
||||
@staticmethod
|
||||
def update_area(db: Session, area_id: uuid.UUID, payload: AreaUpdate) -> Area:
|
||||
area = db.get(Area, area_id)
|
||||
if not area:
|
||||
raise AppError(code="AREA_NOT_FOUND", message="Area not found", status_code=404)
|
||||
|
||||
changed = False
|
||||
if payload.name is not None and payload.name.strip():
|
||||
area.name = payload.name.strip() or area.name
|
||||
changed = True
|
||||
if payload.crs is not None and payload.geometry is None:
|
||||
raise AppError(
|
||||
code="INVALID_AREA_CRS_UPDATE",
|
||||
message="crs can only be supplied together with replacement geometry",
|
||||
status_code=422,
|
||||
)
|
||||
if payload.geometry is not None:
|
||||
try:
|
||||
multipolygon, original_crs = normalize_area_to_epsg4326(
|
||||
payload.geometry,
|
||||
payload.crs or "EPSG:4326",
|
||||
)
|
||||
metric_area = area_m2(multipolygon)
|
||||
except ValueError as exc:
|
||||
raise AppError(code="INVALID_GEOMETRY", message=str(exc), status_code=400) from exc
|
||||
area.geometry = from_shape(multipolygon, srid=4326)
|
||||
area.original_crs = original_crs
|
||||
area.area_m2 = metric_area
|
||||
area.bbox = from_shape(geometry_bbox_polygon(multipolygon), srid=4326)
|
||||
changed = True
|
||||
if not changed:
|
||||
raise AppError(code="NO_CHANGES", message="No updatable fields provided", status_code=422)
|
||||
|
||||
db.add(area)
|
||||
db.commit()
|
||||
db.refresh(area)
|
||||
return area
|
||||
Reference in New Issue
Block a user