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 from fastapi import UploadFile from sqlalchemy.orm import Session from app.models import Area from app.core.errors import AppError from app.db.session import get_db from app.schemas import ( RasterClipRequest, RasterStatsResponse, RasterReprojectRequest, RasterTileRequest, RasterNdviRequest, RasterNdwiRequest, RasterNdbiRequest, 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.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.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 isinstance(dataset.source_metadata, dict) and dataset.source_metadata.get("selection_aggregation"): 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/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))