from __future__ import annotations import json from datetime import datetime from typing import Any from uuid import UUID from uuid import UUID as _UUID from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Response from fastapi import UploadFile from sqlalchemy.orm import Session from app.models import Area, Project from app.core.errors import AppError from app.db.session import get_db from app.schemas import ( RasterClipRequest, RasterStatsResponse, RasterReprojectRequest, RasterTileRequest, RasterNdviRequest, RasterNdwiRequest, RasterNdbiRequest, OrthophotoAcquireRequest, DhmvAcquireRequest, TerrainSelectionRequest, VectorBBoxResponse, VectorBufferRequest, VectorClipRequest, VectorIntersectRequest, VectorSelectionBBox, VectorSelectionDeriveRequest, VectorSelectionRequest, VectorSelectionResponse, ) from app.schemas.job import JobCreate from app.schemas.dataset import DatasetCreateResponse, DatasetTemporalUpdate from app.schemas.operations import VectorOperationResult from app.services.job_service import JobService from app.services.raster_operations_service import RasterOperationsService from app.services.vector_operations_service import VectorOperationsService from app.services.vector_feature_service import VectorFeatureService from app.services.dataset_service import DatasetService from app.services.orthophoto_acquisition_service import OrthophotoAcquisitionService from app.services.dhmv_acquisition_service import DhmvAcquisitionService from app.services.terrain_analysis_service import TerrainAnalysisService from app.utils.response import envelope router = APIRouter(prefix="/projects/{project_id}", tags=["datasets"]) def _parse_metadata_json(raw: str | None, field_name: str) -> dict | None: if raw is None: return None raw = raw.strip() if not raw: return None try: value = json.loads(raw) except json.JSONDecodeError as exc: raise AppError(code="INVALID_JSON", message=f"Invalid JSON for {field_name}", details={"field": field_name}, status_code=400) from exc if not isinstance(value, dict): raise AppError(code="INVALID_JSON", message=f"{field_name} must be a JSON object", details={"field": field_name}, status_code=400) return value def _run_job_sync( db: Session, project_id: UUID, input_dataset_id: UUID, job_type: str, parameters: dict[str, Any], operation, ) -> dict[str, Any]: return JobService.run_sync_job( db=db, project_id=project_id, job_type=job_type, parameters=parameters, operation=operation, input_dataset_id=input_dataset_id, ) @router.post("/datasets/upload", status_code=201, response_model=dict) async def upload_dataset( project_id: UUID, file: UploadFile = File(...), dataset_type: str = Form(...), source: str = Form("user_upload"), area_id: UUID | None = Form(None), dataset_role: str = Form("source"), source_name: str | None = Form(None), reference_layer_name: str | None = Form(None), source_metadata_json: str | None = Form(None), provenance_metadata_json: str | None = Form(None), temporal_series_key: str | None = Form(None), observed_at: datetime | None = Form(None), valid_from: datetime | None = Form(None), valid_to: datetime | None = Form(None), temporal_granularity: str | None = Form(None), source_version: str | None = Form(None), db: Session = Depends(get_db), ): if area_id is not None: area = db.get(Area, area_id) if not area: raise AppError(code="AREA_NOT_FOUND", message="Area not found", status_code=404) if area.project_id != project_id: raise AppError(code="INVALID_DATASET_SCOPE", message="Area does not belong to this project", status_code=400) created = await DatasetService.upload_dataset( db, project_id=project_id, file=file, dataset_type=dataset_type, source=source, dataset_role=dataset_role, source_name=source_name, reference_layer_name=reference_layer_name, source_metadata=_parse_metadata_json(source_metadata_json, "source_metadata_json"), provenance_metadata=_parse_metadata_json(provenance_metadata_json, "provenance_metadata_json"), area_id=area_id, temporal_series_key=temporal_series_key, observed_at=observed_at, valid_from=valid_from, valid_to=valid_to, temporal_granularity=temporal_granularity, source_version=source_version, ) return envelope(created.model_dump()) @router.post("/datasets/orthophoto/acquire", response_model=dict) def acquire_bounded_orthophoto( project_id: UUID, payload: OrthophotoAcquireRequest, db: Session = Depends(get_db), ): job = JobService.run_sync_job( db=db, project_id=project_id, job_type="raster.orthophoto.acquire", parameters=payload.model_dump(mode="json"), operation=lambda: OrthophotoAcquisitionService.acquire(db, project_id, payload), ) return envelope(job) @router.get("/datasets/orthophoto/products", response_model=dict) def list_orthophoto_products(project_id: UUID, db: Session = Depends(get_db)): if not db.get(Project, project_id): raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404) items = OrthophotoAcquisitionService.list_products() return envelope({"items": items, "total": len(items)}) @router.post("/datasets/dhmv/acquire", response_model=dict) def acquire_bounded_dhmv( project_id: UUID, payload: DhmvAcquireRequest, db: Session = Depends(get_db), ): job = JobService.run_sync_job( db=db, project_id=project_id, job_type="raster.dhmv.acquire", parameters=payload.model_dump(mode="json"), operation=lambda: DhmvAcquisitionService.acquire(db, project_id, payload), ) return envelope(job) @router.get("/datasets/dhmv/products", response_model=dict) def list_dhmv_products(project_id: UUID, db: Session = Depends(get_db)): if not db.get(Project, project_id): raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404) items = DhmvAcquisitionService.list_products() return envelope({"items": items, "total": len(items)}) @router.get("/datasets", response_model=dict) def list_datasets( project_id: UUID, limit: int = Query(default=50, ge=1, le=200), offset: int = Query(default=0, ge=0), db: Session = Depends(get_db), ): datasets, total = DatasetService.list_datasets(db, project_id, limit=limit, offset=offset) return envelope({"items": [item.model_dump() for item in datasets], "total": total, "limit": limit, "offset": offset}) @router.get("/datasets/{dataset_id}", response_model=dict) def get_dataset( project_id: UUID, dataset_id: UUID, db: Session = Depends(get_db), ): dataset = DatasetService.get_dataset(db, dataset_id) if dataset.project_id != project_id: raise HTTPException(status_code=404, detail="Dataset not found") return envelope(DatasetCreateResponse.model_validate(dataset).model_dump()) @router.patch("/datasets/{dataset_id}/temporal", response_model=dict) def update_dataset_temporal_metadata( project_id: UUID, dataset_id: UUID, payload: DatasetTemporalUpdate, db: Session = Depends(get_db), ): dataset = DatasetService.get_dataset(db, dataset_id) if dataset.project_id != project_id: raise HTTPException(status_code=404, detail="Dataset not found") updated = DatasetService.update_temporal_metadata(db, dataset_id, payload) return envelope(updated.model_dump()) @router.get("/datasets/{dataset_id}/versions", response_model=dict) def list_dataset_versions( project_id: UUID, dataset_id: UUID, db: Session = Depends(get_db), ): dataset = DatasetService.get_dataset(db, dataset_id) if dataset.project_id != project_id: raise HTTPException(status_code=404, detail="Dataset not found") versions = DatasetService.list_versions(db, dataset_id) return envelope({"items": [item.model_dump() for item in versions], "total": len(versions)}) @router.post("/datasets/{dataset_id}/metadata/refresh", response_model=dict) def refresh_dataset_metadata( project_id: UUID, dataset_id: UUID, db: Session = Depends(get_db), ): dataset = DatasetService.get_dataset(db, dataset_id) if dataset.project_id != project_id: raise HTTPException(status_code=404, detail="Dataset not found") refreshed = DatasetService.refresh_metadata(db, dataset_id) return envelope(refreshed.model_dump()) @router.get("/datasets/{dataset_id}/vector/inspect", response_model=dict) def inspect_vector_dataset( project_id: UUID, dataset_id: UUID, db: Session = Depends(get_db), ): dataset = DatasetService.get_dataset(db, dataset_id) if dataset.project_id != project_id: raise HTTPException(status_code=404, detail="Dataset not found") return envelope(VectorOperationsService.inspect(db, dataset_id).model_dump()) @router.get("/datasets/{dataset_id}/vector/bbox", response_model=dict) def vector_bbox( project_id: UUID, dataset_id: UUID, db: Session = Depends(get_db), ): dataset = DatasetService.get_dataset(db, dataset_id) if dataset.project_id != project_id: raise HTTPException(status_code=404, detail="Dataset not found") payload = VectorOperationsService.bbox(db, dataset_id) return envelope(VectorBBoxResponse(**payload).model_dump()) @router.get("/datasets/{dataset_id}/vector/stats", response_model=dict) def vector_stats( project_id: UUID, dataset_id: UUID, db: Session = Depends(get_db), ): dataset = DatasetService.get_dataset(db, dataset_id) if dataset.project_id != project_id: raise HTTPException(status_code=404, detail="Dataset not found") return envelope(VectorOperationsService.stats(db, dataset_id)) @router.post("/datasets/{dataset_id}/vector/select", response_model=dict) def select_vector_features( project_id: UUID, dataset_id: UUID, payload: VectorSelectionRequest, db: Session = Depends(get_db), ): dataset = DatasetService.get_dataset(db, dataset_id) if dataset.project_id != project_id: raise HTTPException(status_code=404, detail="Dataset not found") if dataset.dataset_type not in {"vector", "geojson"}: raise AppError(code="DATASET_NOT_VECTOR", message="Area selection requires a vector dataset", status_code=400) selection_area = None if payload.area_id is not None: selection_area = db.get(Area, payload.area_id) if selection_area is None or selection_area.project_id != project_id: raise AppError(code="AREA_NOT_FOUND", message="Area not found", status_code=404) selection_kwargs = { "dataset_id": dataset_id, "bbox": payload.bbox.model_dump(), "limit": payload.limit, } full_dataset_area = False if selection_area is not None: full_dataset_area = VectorFeatureService.can_use_full_area_fast_path(dataset, selection_area.id) selection_kwargs.update( selection_geometry=selection_area.geometry, selection_area_id=selection_area.id, full_dataset_area=full_dataset_area, ) result = VectorFeatureService.select_features_by_bbox(db, **selection_kwargs) if VectorFeatureService.supports_selection_summary(dataset): summary_kwargs = { "dataset": dataset, "bbox": payload.bbox.model_dump(), "total_feature_count": result.get("total_feature_count"), } if selection_area is not None: summary_kwargs["selection_geometry"] = selection_area.geometry summary_kwargs["full_dataset_area"] = full_dataset_area result["summary"] = VectorFeatureService.summarize_features_by_bbox(db, **summary_kwargs) return envelope(VectorSelectionResponse(**result).model_dump(exclude_none=True)) @router.post("/datasets/{dataset_id}/vector/select/derive", status_code=201, response_model=dict) def derive_vector_selection_dataset( project_id: UUID, dataset_id: UUID, payload: VectorSelectionDeriveRequest, db: Session = Depends(get_db), ): dataset = DatasetService.get_dataset(db, dataset_id) if dataset.project_id != project_id: raise HTTPException(status_code=404, detail="Dataset not found") if dataset.dataset_type not in {"vector", "geojson"}: raise AppError(code="DATASET_NOT_VECTOR", message="Area selection requires a vector dataset", status_code=400) derived = VectorOperationsService.derive_selection_dataset( db=db, dataset_id=dataset_id, bbox=payload.bbox.model_dump(), limit=payload.limit, output_name=payload.output_name, ) return envelope(derived.model_dump()) @router.post("/datasets/{dataset_id}/vector/clip", status_code=201, response_model=dict) def clip_vector_dataset( project_id: UUID, dataset_id: UUID, payload: VectorClipRequest, db: Session = Depends(get_db), ): dataset = DatasetService.get_dataset(db, dataset_id) if dataset.project_id != project_id: raise HTTPException(status_code=404, detail="Dataset not found") job = _run_job_sync( db=db, project_id=project_id, input_dataset_id=dataset_id, job_type="vector.clip", parameters=payload.model_dump(), operation=lambda: VectorOperationsService.clip_by_area( db, dataset_id=dataset_id, area_id=payload.area_id, output_name=payload.output_name, ), ) return envelope(job) @router.post("/datasets/{dataset_id}/vector/buffer", status_code=201, response_model=dict) def buffer_vector_dataset( project_id: UUID, dataset_id: UUID, payload: VectorBufferRequest, db: Session = Depends(get_db), ): dataset = DatasetService.get_dataset(db, dataset_id) if dataset.project_id != project_id: raise HTTPException(status_code=404, detail="Dataset not found") job = _run_job_sync( db=db, project_id=project_id, input_dataset_id=dataset_id, job_type="vector.buffer", parameters=payload.model_dump(), operation=lambda: VectorOperationsService.buffer( db, dataset_id=dataset_id, distance_m=payload.distance_m, dissolve=payload.dissolve, output_name=payload.output_name, ), ) return envelope(job) @router.post("/datasets/{dataset_id}/vector/intersect", status_code=201, response_model=dict) def intersect_vector_dataset( project_id: UUID, dataset_id: UUID, payload: VectorIntersectRequest, db: Session = Depends(get_db), ): dataset = DatasetService.get_dataset(db, dataset_id) if dataset.project_id != project_id: raise HTTPException(status_code=404, detail="Dataset not found") job = _run_job_sync( db=db, project_id=project_id, input_dataset_id=dataset_id, job_type="vector.intersect", parameters=payload.model_dump(), operation=lambda: VectorOperationsService.intersect( db, source_dataset_id=dataset_id, target_dataset_id=UUID(payload.other_dataset_id), output_name=payload.output_name, ), ) return envelope(job) @router.get("/datasets/{dataset_id}/vector/summary", response_model=dict) def vector_dataset_summary( project_id: UUID, dataset_id: UUID, db: Session = Depends(get_db), ): dataset = DatasetService.get_dataset(db, dataset_id) if dataset.project_id != project_id: raise HTTPException(status_code=404, detail="Dataset not found") return envelope(DatasetService.vector_summary(db, dataset_id)) @router.get("/datasets/{dataset_id}/raster/inspect", response_model=dict) def raster_dataset_inspect( project_id: UUID, dataset_id: UUID, db: Session = Depends(get_db), ): dataset = DatasetService.get_dataset(db, dataset_id) if dataset.project_id != project_id: raise HTTPException(status_code=404, detail="Dataset not found") payload = RasterOperationsService.inspect(db, dataset_id) return envelope(payload) @router.get("/datasets/{dataset_id}/raster/preview", response_model=dict) def raster_preview_readiness( project_id: UUID, dataset_id: UUID, db: Session = Depends(get_db), ): dataset = DatasetService.get_dataset(db, dataset_id) if dataset.project_id != project_id: raise HTTPException(status_code=404, detail="Dataset not found") return envelope(RasterOperationsService.preview(db, dataset_id)) @router.get("/datasets/{dataset_id}/raster/image") def raster_orthophoto_image( project_id: UUID, dataset_id: UUID, db: Session = Depends(get_db), ): content = OrthophotoAcquisitionService.render_png(db, project_id, dataset_id) return Response( content=content, media_type="image/png", headers={"Cache-Control": "private, max-age=86400"}, ) @router.post("/datasets/{dataset_id}/raster/terrain/select", response_model=dict) def raster_terrain_selection( project_id: UUID, dataset_id: UUID, payload: TerrainSelectionRequest, db: Session = Depends(get_db), ): return envelope(TerrainAnalysisService.analyze(db, project_id, dataset_id, payload)) @router.get("/datasets/{dataset_id}/raster/terrain/image") def raster_terrain_image( project_id: UUID, dataset_id: UUID, db: Session = Depends(get_db), ): content = TerrainAnalysisService.render_png(db, project_id, dataset_id) return Response( content=content, media_type="image/png", headers={"Cache-Control": "private, max-age=86400"}, ) @router.get("/datasets/{dataset_id}/raster/stats", response_model=dict) def raster_stats( project_id: UUID, dataset_id: UUID, db: Session = Depends(get_db), ): dataset = DatasetService.get_dataset(db, dataset_id) if dataset.project_id != project_id: raise HTTPException(status_code=404, detail="Dataset not found") payload = RasterOperationsService.stats(db, dataset_id) return envelope(RasterStatsResponse(**payload).model_dump()) @router.post("/datasets/{dataset_id}/raster/reproject", status_code=201, response_model=dict) def raster_reproject_dataset( project_id: UUID, dataset_id: UUID, payload: RasterReprojectRequest, db: Session = Depends(get_db), ): dataset = DatasetService.get_dataset(db, dataset_id) if dataset.project_id != project_id: raise HTTPException(status_code=404, detail="Dataset not found") job = _run_job_sync( db=db, project_id=project_id, input_dataset_id=dataset_id, job_type="raster.reproject", parameters=payload.model_dump(), operation=lambda: RasterOperationsService.reproject( db, dataset_id, target_crs=payload.target_crs, output_name=payload.output_name, resampling=payload.resampling, ), ) return envelope(job) @router.post("/datasets/{dataset_id}/raster/clip", status_code=201, response_model=dict) def raster_clip_dataset( project_id: UUID, dataset_id: UUID, payload: RasterClipRequest, db: Session = Depends(get_db), ): dataset = DatasetService.get_dataset(db, dataset_id) if dataset.project_id != project_id: raise HTTPException(status_code=404, detail="Dataset not found") job = _run_job_sync( db=db, project_id=project_id, input_dataset_id=dataset_id, job_type="raster.clip", parameters=payload.model_dump(), operation=lambda: RasterOperationsService.clip(db, dataset_id, UUID(payload.area_id), payload.output_name), ) return envelope(job) @router.post("/datasets/{dataset_id}/raster/tile", status_code=201, response_model=dict) def raster_tile_dataset( project_id: UUID, dataset_id: UUID, payload: RasterTileRequest, db: Session = Depends(get_db), ): dataset = DatasetService.get_dataset(db, dataset_id) if dataset.project_id != project_id: raise HTTPException(status_code=404, detail="Dataset not found") job = _run_job_sync( db=db, project_id=project_id, input_dataset_id=dataset_id, job_type="raster.tile", parameters=payload.model_dump(), operation=lambda: RasterOperationsService.tile( db, dataset_id, tile_size=payload.tile_size, overlap=payload.overlap, output_name=payload.output_name, ), ) return envelope(job) @router.post("/datasets/{dataset_id}/raster/indices/ndvi", status_code=201, response_model=dict) def raster_ndvi_dataset( project_id: UUID, dataset_id: UUID, payload: RasterNdviRequest, db: Session = Depends(get_db), ): dataset = DatasetService.get_dataset(db, dataset_id) if dataset.project_id != project_id: raise HTTPException(status_code=404, detail="Dataset not found") job = _run_job_sync( db=db, project_id=project_id, input_dataset_id=dataset_id, job_type="raster.ndvi", parameters=payload.model_dump(), operation=lambda: RasterOperationsService.ndvi( db, dataset_id=dataset_id, nir_band=payload.nir_band, red_band=payload.red_band, output_name=payload.output_name, ), ) return envelope(job) @router.post("/datasets/{dataset_id}/raster/indices/ndwi", status_code=201, response_model=dict) def raster_ndwi_dataset( project_id: UUID, dataset_id: UUID, payload: RasterNdwiRequest, db: Session = Depends(get_db), ): dataset = DatasetService.get_dataset(db, dataset_id) if dataset.project_id != project_id: raise HTTPException(status_code=404, detail="Dataset not found") job = _run_job_sync( db=db, project_id=project_id, input_dataset_id=dataset_id, job_type="raster.ndwi", parameters=payload.model_dump(), operation=lambda: RasterOperationsService.ndwi( db, dataset_id=dataset_id, green_band=payload.green_band, nir_band=payload.nir_band, output_name=payload.output_name, ), ) return envelope(job) @router.post("/datasets/{dataset_id}/raster/indices/ndbi", status_code=201, response_model=dict) def raster_ndbi_dataset( project_id: UUID, dataset_id: UUID, payload: RasterNdbiRequest, db: Session = Depends(get_db), ): dataset = DatasetService.get_dataset(db, dataset_id) if dataset.project_id != project_id: raise HTTPException(status_code=404, detail="Dataset not found") job = _run_job_sync( db=db, project_id=project_id, input_dataset_id=dataset_id, job_type="raster.ndbi", parameters=payload.model_dump(), operation=lambda: RasterOperationsService.ndbi( db, dataset_id=dataset_id, swir_band=payload.swir_band, nir_band=payload.nir_band, output_name=payload.output_name, ), ) return envelope(job) @router.get("/datasets/{dataset_id}/raster/metadata", response_model=dict) def raster_dataset_metadata( project_id: UUID, dataset_id: UUID, db: Session = Depends(get_db), ): dataset = DatasetService.get_dataset(db, dataset_id) if dataset.project_id != project_id: raise HTTPException(status_code=404, detail="Dataset not found") return envelope(RasterOperationsService.metadata(db, dataset_id)) @router.get("/datasets/{dataset_id}/content", response_model=dict) def dataset_content( project_id: UUID, dataset_id: UUID, db: Session = Depends(get_db), ): dataset = DatasetService.get_dataset(db, dataset_id) if dataset.project_id != project_id: raise HTTPException(status_code=404, detail="Dataset not found") return envelope(DatasetService.get_dataset_geojson(db, dataset_id))