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