from __future__ import annotations from typing import Any from uuid import UUID from geoalchemy2.functions import ST_Intersects, ST_MakeEnvelope from geoalchemy2.shape import from_shape from geoalchemy2.shape import to_shape from shapely.geometry import mapping from shapely.geometry import shape from shapely.validation import make_valid from app.core.errors import AppError from app.models import VectorFeature class VectorFeatureService: @staticmethod def _normalize_selection_bbox(bbox: dict[str, Any]) -> dict[str, float | str]: try: min_x = float(bbox["min_x"]) min_y = float(bbox["min_y"]) max_x = float(bbox["max_x"]) max_y = float(bbox["max_y"]) except (KeyError, TypeError, ValueError) as exc: raise AppError( code="INVALID_SELECTION_BBOX", message="Selection bbox must include numeric min_x, min_y, max_x and max_y values", status_code=400, ) from exc crs = str(bbox.get("crs") or "EPSG:4326").upper() if crs != "EPSG:4326": raise AppError( code="UNSUPPORTED_SELECTION_CRS", message="Map selection currently supports EPSG:4326 bbox coordinates only", details={"crs": crs}, status_code=400, ) if min_x >= max_x or min_y >= max_y: raise AppError( code="INVALID_SELECTION_BBOX", message="Selection bbox must have min_x < max_x and min_y < max_y", status_code=400, ) if min_x < -180 or max_x > 180 or min_y < -90 or max_y > 90: raise AppError( code="INVALID_SELECTION_BBOX", message="Selection bbox is outside EPSG:4326 longitude/latitude bounds", status_code=400, ) return {"min_x": min_x, "min_y": min_y, "max_x": max_x, "max_y": max_y, "crs": "EPSG:4326"} @staticmethod def _row_to_geojson_feature(row: VectorFeature) -> dict[str, Any]: geometry_value = row.geometry try: geometry = geometry_value if hasattr(geometry_value, "__geo_interface__") else to_shape(geometry_value) except Exception as exc: raise AppError( code="INVALID_VECTOR_FEATURE_GEOMETRY", message="Persisted vector feature geometry could not be converted to GeoJSON", details={"vector_feature_id": str(row.id)}, status_code=500, ) from exc properties = dict(row.properties_json or {}) properties.update( { "vector_feature_id": str(row.id), "dataset_id": str(row.dataset_id), "source_feature_id": row.source_feature_id, "feature_class": row.feature_class, } ) return { "type": "Feature", "id": str(row.id), "geometry": mapping(geometry), "properties": properties, } @staticmethod def select_features_by_bbox( db, dataset_id: UUID, bbox: dict[str, Any], limit: int = 100, ) -> dict[str, Any]: normalized_bbox = VectorFeatureService._normalize_selection_bbox(bbox) safe_limit = max(1, min(int(limit), 1000)) rows = ( db.query(VectorFeature) .filter(VectorFeature.dataset_id == dataset_id) .filter( ST_Intersects( VectorFeature.geometry, ST_MakeEnvelope( normalized_bbox["min_x"], normalized_bbox["min_y"], normalized_bbox["max_x"], normalized_bbox["max_y"], 4326, ), ) ) .order_by(VectorFeature.created_at.asc()) .limit(safe_limit + 1) .all() ) truncated = len(rows) > safe_limit selected_rows = rows[:safe_limit] features = [VectorFeatureService._row_to_geojson_feature(row) for row in selected_rows] return { "selection_bbox": normalized_bbox, "feature_count": len(features), "limit": safe_limit, "truncated": truncated, "geojson": { "type": "FeatureCollection", "features": features, }, } @staticmethod def persist_geojson_features( db, dataset_id: UUID, payload: dict[str, Any], feature_class: str | None = None, *, commit: bool = True, ) -> list[VectorFeature]: features = payload.get("features") if payload.get("type") != "FeatureCollection" or not isinstance(features, list): raise AppError(code="INVALID_GEOJSON", message="GeoJSON payload must be a FeatureCollection", status_code=400) persisted: list[VectorFeature] = [] for index, feature in enumerate(features): if not isinstance(feature, dict): raise AppError(code="INVALID_GEOJSON", message=f"Feature {index} must be an object", status_code=400) geometry_payload = feature.get("geometry") if geometry_payload is None: continue try: geometry = shape(geometry_payload) except Exception as exc: raise AppError(code="INVALID_GEOJSON", message=f"Invalid feature geometry at index {index}", status_code=400) from exc if geometry.is_empty: continue if not geometry.is_valid: geometry = make_valid(geometry) if geometry.is_empty or not geometry.is_valid: raise AppError(code="INVALID_GEOMETRY", message=f"Invalid feature geometry at index {index}", status_code=400) properties = feature.get("properties") if isinstance(feature.get("properties"), dict) else {} source_feature_id = feature.get("id") if source_feature_id is None: source_feature_id = properties.get("id") or properties.get("source_feature_id") row = VectorFeature( dataset_id=dataset_id, feature_class=feature_class, source_feature_id=str(source_feature_id) if source_feature_id is not None else None, properties_json=properties, geometry=from_shape(geometry, srid=4326), ) db.add(row) persisted.append(row) if commit: db.commit() for row in persisted: db.refresh(row) return persisted