Files
geointel/backend/app/api/routes/datasets.py
T
Codex c2101ea8f9
GeoIntel release gates / Compile, test, contracts and builds (push) Canceled after 0s
GeoIntel release gates / Python and npm vulnerability policy (push) Canceled after 0s
GeoIntel release gates / GIS image, SBOM and container scan (push) Canceled after 0s
feat: complete governed Walloon coverage sources
2026-07-22 03:39:08 +02:00

1288 lines
42 KiB
Python

from __future__ import annotations
import json
from datetime import datetime
from typing import Any
from uuid import 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 (
BathymetryPartitionFinalizationResult,
BathymetrySourceProbeRead,
BathymetrySourceRead,
DatasetList,
DhmvProductRead,
Envelope,
FloodHazardProductRead,
FloodHazardSelectionResponse,
GeoJsonFeatureCollection,
GrbProductRead,
GrbRefreshPlan,
ItemList,
JobRead,
OfficialVectorProductRead,
OrthophotoProductRead,
RasterMetadataResponse,
RasterOperationResult,
RasterPreviewResponse,
RasterClipRequest,
RasterStatsResponse,
RasterReprojectRequest,
RasterTileRequest,
RasterNdviRequest,
RasterNdwiRequest,
RasterNdbiRequest,
OrthophotoAcquireRequest,
DhmvAcquireRequest,
TerrainPartitionSelectionRequest,
TerrainSelectionResponse,
TerrainSelectionRequest,
FloodHazardAcquireRequest,
FloodHazardPartitionSelectionRequest,
FloodHazardSelectionRequest,
BathymetryPartitionFinalizeRequest,
BathymetryProfileAcquireRequest,
BathymetryRasterSelectionRequest,
BathymetryRasterSelectionResponse,
MdkBathymetryAcquireRequest,
ThematicRasterAcquireRequest,
ThematicRasterProductRead,
ThematicRasterSelectionResponse,
ThematicRasterSelectionRequest,
GrbAcquireRequest,
OfficialVectorAcquireRequest,
VectorBBoxResponse,
VectorBufferRequest,
VectorClipRequest,
VectorIntersectRequest,
VectorOperationResult,
VectorSelectionBBox, # noqa: F401 - retained as a route-module compatibility export
VectorSelectionDeriveRequest,
VectorSelectionRequest,
VectorSelectionResponse,
VectorStatsResponse,
)
from app.schemas.dataset import (
DatasetCreateResponse,
DatasetTemporalUpdate,
DatasetVectorSummary,
DatasetVersionRead,
)
from app.schemas.source_catalog import SourceCatalogProbeReport
from app.schemas.source_freshness import SourceFreshnessReport
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.source_freshness_service import SourceFreshnessService
from app.services.source_catalog_probe_service import SourceCatalogProbeService
from app.services.grb_refresh_plan_service import GrbRefreshPlanService
from app.services.grb_acquisition_service import GrbAcquisitionService
from app.services.official_vector_acquisition_service import OfficialVectorAcquisitionService
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.services.flood_hazard_acquisition_service import FloodHazardAcquisitionService
from app.services.flood_hazard_analysis_service import FloodHazardAnalysisService
from app.services.bathymetry_profile_acquisition_service import BathymetryProfileAcquisitionService
from app.services.bathymetry_raster_analysis_service import BathymetryRasterAnalysisService
from app.services.mdk_bathymetry_acquisition_service import MdkBathymetryAcquisitionService
from app.services.mdk_bathymetry_probe_service import MdkBathymetryProbeService
from app.services.thematic_raster_acquisition_service import ThematicRasterAcquisitionService
from app.services.thematic_raster_analysis_service import ThematicRasterAnalysisService
from app.services.walous_land_cover_service import WalousLandCoverService
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=Envelope[DatasetCreateResponse],
)
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=Envelope[JobRead])
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=Envelope[ItemList[OrthophotoProductRead]],
)
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=Envelope[JobRead])
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=Envelope[ItemList[DhmvProductRead]],
)
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.post("/datasets/grb/acquire", response_model=Envelope[JobRead])
def acquire_bounded_grb(
project_id: UUID,
payload: GrbAcquireRequest,
db: Session = Depends(get_db),
):
job = JobService.run_sync_job(
db=db,
project_id=project_id,
job_type="vector.grb.acquire",
parameters=payload.model_dump(mode="json"),
operation=lambda: GrbAcquisitionService.acquire(db, project_id, payload),
)
return envelope(job)
@router.get(
"/datasets/grb/products",
response_model=Envelope[ItemList[GrbProductRead]],
)
def list_grb_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 = GrbAcquisitionService.list_products()
return envelope({"items": items, "total": len(items)})
@router.post("/datasets/official-vector/acquire", response_model=Envelope[JobRead])
def acquire_bounded_official_vector(
project_id: UUID,
payload: OfficialVectorAcquireRequest,
db: Session = Depends(get_db),
):
job = JobService.run_sync_job(
db=db,
project_id=project_id,
job_type="vector.official.acquire",
parameters=payload.model_dump(mode="json"),
operation=lambda: OfficialVectorAcquisitionService.acquire(db, project_id, payload),
)
return envelope(job)
@router.get(
"/datasets/official-vector/products",
response_model=Envelope[ItemList[OfficialVectorProductRead]],
)
def list_official_vector_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 = OfficialVectorAcquisitionService.list_products()
return envelope({"items": items, "total": len(items)})
@router.post("/datasets/flood-hazard/acquire", response_model=Envelope[JobRead])
def acquire_bounded_flood_hazard(
project_id: UUID,
payload: FloodHazardAcquireRequest,
db: Session = Depends(get_db),
):
job = JobService.run_sync_job(
db=db,
project_id=project_id,
job_type="raster.flood_hazard.acquire",
parameters=payload.model_dump(mode="json"),
operation=lambda: FloodHazardAcquisitionService.acquire(db, project_id, payload),
)
return envelope(job)
@router.get(
"/datasets/flood-hazard/products",
response_model=Envelope[ItemList[FloodHazardProductRead]],
)
def list_flood_hazard_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 = FloodHazardAcquisitionService.list_products()
return envelope({"items": items, "total": len(items)})
@router.get(
"/datasets/bathymetry/sources",
response_model=Envelope[ItemList[BathymetrySourceRead]],
)
def list_bathymetry_sources(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 = BathymetryProfileAcquisitionService.list_sources()
return envelope({"items": items, "total": len(items)})
@router.get(
"/datasets/bathymetry/sources/mdk_bcp_bathymetry/readiness",
response_model=Envelope[BathymetrySourceProbeRead],
)
def probe_mdk_bathymetry_readiness(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)
return envelope(MdkBathymetryProbeService.probe())
@router.post(
"/datasets/bathymetry/mdk/acquire",
response_model=Envelope[JobRead],
)
def acquire_bounded_mdk_bathymetry(
project_id: UUID,
payload: MdkBathymetryAcquireRequest,
db: Session = Depends(get_db),
):
job = JobService.run_sync_job(
db=db,
project_id=project_id,
job_type="raster.mdk_bathymetry.acquire",
parameters=payload.model_dump(mode="json"),
operation=lambda: MdkBathymetryAcquisitionService.acquire(db, project_id, payload),
)
return envelope(job)
@router.post(
"/datasets/bathymetry/profiles/acquire",
response_model=Envelope[JobRead],
)
def acquire_bounded_bathymetry_profiles(
project_id: UUID,
payload: BathymetryProfileAcquireRequest,
db: Session = Depends(get_db),
):
job = JobService.run_sync_job(
db=db,
project_id=project_id,
job_type="vector.bathymetry_profiles.acquire",
parameters=payload.model_dump(mode="json"),
operation=lambda: BathymetryProfileAcquisitionService.acquire(db, project_id, payload),
)
return envelope(job)
@router.post(
"/datasets/bathymetry/profiles/partitions/finalize",
response_model=Envelope[BathymetryPartitionFinalizationResult],
)
def finalize_bathymetry_profile_partitions(
project_id: UUID,
payload: BathymetryPartitionFinalizeRequest,
db: Session = Depends(get_db),
):
return envelope(BathymetryProfileAcquisitionService.finalize_partitions(db, project_id, payload))
@router.post(
"/datasets/bathymetry/profiles/partitions/select",
response_model=Envelope[VectorSelectionResponse],
)
def select_bathymetry_profile_partitions(
project_id: UUID,
payload: VectorSelectionRequest,
db: Session = Depends(get_db),
):
selection_geometry = None
selection_area_id = None
partition_area_id = 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_geometry, _covers_full_area = VectorFeatureService.constrain_bbox_to_area(
payload.bbox.model_dump(),
selection_area.geometry,
)
selection_area_id = selection_area.id
if str(selection_area.name or "").lower().startswith("gemeente "):
partition_area_id = selection_area.id
result = VectorFeatureService.select_partitioned_features_by_bbox(
db,
project_id=project_id,
source_name=BathymetryProfileAcquisitionService.PROVIDER,
partition_scope_key="flanders",
bbox=payload.bbox.model_dump(),
limit=payload.limit,
selection_geometry=selection_geometry,
selection_area_id=selection_area_id,
partition_area_id=partition_area_id,
)
return envelope(VectorSelectionResponse(**result).model_dump(exclude_none=True))
@router.post("/datasets/thematic-raster/acquire", response_model=Envelope[JobRead])
def acquire_bounded_thematic_raster(
project_id: UUID,
payload: ThematicRasterAcquireRequest,
db: Session = Depends(get_db),
):
job = JobService.run_sync_job(
db=db,
project_id=project_id,
job_type="raster.thematic.acquire",
parameters=payload.model_dump(mode="json"),
operation=lambda: ThematicRasterAcquisitionService.acquire(db, project_id, payload),
)
return envelope(job)
@router.get(
"/datasets/thematic-raster/products",
response_model=Envelope[ItemList[ThematicRasterProductRead]],
)
def list_thematic_raster_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 = ThematicRasterAcquisitionService.list_products()
return envelope({"items": items, "total": len(items)})
@router.post("/datasets/walous/acquire", response_model=Envelope[JobRead])
def acquire_bounded_walous_land_cover(
project_id: UUID,
payload: ThematicRasterAcquireRequest,
db: Session = Depends(get_db),
):
job = JobService.run_sync_job(
db=db,
project_id=project_id,
job_type="raster.walous.acquire",
parameters=payload.model_dump(mode="json"),
operation=lambda: WalousLandCoverService.acquire(db, project_id, payload),
)
return envelope(job)
@router.get(
"/datasets/walous/products",
response_model=Envelope[ItemList[ThematicRasterProductRead]],
)
def list_walous_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 = WalousLandCoverService.list_products()
return envelope({"items": items, "total": len(items)})
@router.get("/datasets", response_model=Envelope[DatasetList])
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/source-freshness",
response_model=Envelope[SourceFreshnessReport],
)
def audit_dataset_source_freshness(
project_id: UUID,
db: Session = Depends(get_db),
):
report = SourceFreshnessService.audit_project(db, project_id)
return envelope(report.model_dump())
@router.get(
"/datasets/source-catalog-probes",
response_model=Envelope[SourceCatalogProbeReport],
)
def probe_dataset_source_catalogs(
project_id: UUID,
refresh: bool = Query(default=False),
db: Session = Depends(get_db),
):
report = SourceCatalogProbeService.audit_project(db, project_id, force=refresh)
return envelope(report.model_dump())
@router.get(
"/datasets/grb-refresh-plan",
response_model=Envelope[GrbRefreshPlan],
)
def plan_grb_dataset_refresh(
project_id: UUID,
scope: str = Query(default=GrbRefreshPlanService.SCOPE),
refresh_catalog: bool = Query(default=False),
db: Session = Depends(get_db),
):
report = GrbRefreshPlanService.build(
db,
project_id,
scope=scope,
refresh_catalog=refresh_catalog,
)
return envelope(report.model_dump())
@router.get("/datasets/{dataset_id}", response_model=Envelope[DatasetCreateResponse])
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=Envelope[DatasetCreateResponse],
)
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=Envelope[ItemList[DatasetVersionRead]],
)
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=Envelope[DatasetCreateResponse],
)
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=Envelope[VectorOperationResult],
)
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=Envelope[VectorBBoxResponse],
)
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=Envelope[VectorStatsResponse],
)
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=Envelope[VectorSelectionResponse],
)
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
preclipped_partition_filter = None
if selection_area is not None:
selection_geometry, covers_full_area = VectorFeatureService.constrain_bbox_to_area(
payload.bbox.model_dump(),
selection_area.geometry,
)
dataset_is_preclipped_to_area = VectorFeatureService.can_use_full_area_fast_path(
dataset,
selection_area.id,
)
full_dataset_area = covers_full_area and dataset_is_preclipped_to_area
preclipped_partition_filter = VectorFeatureService.preclipped_partition_filter(
dataset,
getattr(selection_area, "name", None),
)
selection_kwargs.update(
selection_geometry=None if dataset_is_preclipped_to_area else selection_geometry,
selection_area_id=selection_area.id,
full_dataset_area=full_dataset_area,
preclipped_partition_filter=preclipped_partition_filter,
)
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"] = None if dataset_is_preclipped_to_area else selection_geometry
summary_kwargs["full_dataset_area"] = full_dataset_area
summary_kwargs["preclipped_partition_filter"] = preclipped_partition_filter
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=Envelope[DatasetCreateResponse],
)
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)
selection_geometry = None
selection_area_id = 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_geometry, _covers_full_area = VectorFeatureService.constrain_bbox_to_area(
payload.bbox.model_dump(),
selection_area.geometry,
)
selection_area_id = selection_area.id
derived = VectorOperationsService.derive_selection_dataset(
db=db,
dataset_id=dataset_id,
bbox=payload.bbox.model_dump(),
selection_geometry=selection_geometry,
selection_area_id=selection_area_id,
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=Envelope[JobRead],
)
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=Envelope[JobRead],
)
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=Envelope[JobRead],
)
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=Envelope[DatasetVectorSummary],
)
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=Envelope[RasterOperationResult],
)
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=Envelope[RasterPreviewResponse],
)
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=Envelope[TerrainSelectionResponse],
)
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.post(
"/datasets/raster/terrain/select",
response_model=Envelope[TerrainSelectionResponse],
)
def partitioned_raster_terrain_selection(
project_id: UUID,
payload: TerrainPartitionSelectionRequest,
db: Session = Depends(get_db),
):
return envelope(TerrainAnalysisService.analyze_partitions(db, project_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.post(
"/datasets/{dataset_id}/raster/bathymetry/select",
response_model=Envelope[BathymetryRasterSelectionResponse],
)
def raster_bathymetry_selection(
project_id: UUID,
dataset_id: UUID,
payload: BathymetryRasterSelectionRequest,
db: Session = Depends(get_db),
):
return envelope(BathymetryRasterAnalysisService.analyze(db, project_id, dataset_id, payload))
@router.get("/datasets/{dataset_id}/raster/bathymetry/image")
def raster_bathymetry_image(
project_id: UUID,
dataset_id: UUID,
db: Session = Depends(get_db),
):
content = BathymetryRasterAnalysisService.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/flood-hazard/select",
response_model=Envelope[FloodHazardSelectionResponse],
)
def raster_flood_hazard_selection(
project_id: UUID,
dataset_id: UUID,
payload: FloodHazardSelectionRequest,
db: Session = Depends(get_db),
):
return envelope(FloodHazardAnalysisService.analyze(db, project_id, dataset_id, payload))
@router.post(
"/datasets/raster/flood-hazard/select",
response_model=Envelope[FloodHazardSelectionResponse],
)
def partitioned_raster_flood_hazard_selection(
project_id: UUID,
payload: FloodHazardPartitionSelectionRequest,
db: Session = Depends(get_db),
):
return envelope(FloodHazardAnalysisService.analyze_partitions(db, project_id, payload))
@router.get("/datasets/{dataset_id}/raster/flood-hazard/image")
def raster_flood_hazard_image(
project_id: UUID,
dataset_id: UUID,
db: Session = Depends(get_db),
):
content = FloodHazardAnalysisService.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/thematic/select",
response_model=Envelope[ThematicRasterSelectionResponse],
)
def raster_thematic_selection(
project_id: UUID,
dataset_id: UUID,
payload: ThematicRasterSelectionRequest,
db: Session = Depends(get_db),
):
return envelope(ThematicRasterAnalysisService.analyze(db, project_id, dataset_id, payload))
@router.get("/datasets/{dataset_id}/raster/thematic/image")
def raster_thematic_image(
project_id: UUID,
dataset_id: UUID,
db: Session = Depends(get_db),
):
content = ThematicRasterAnalysisService.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/walous/select",
response_model=Envelope[ThematicRasterSelectionResponse],
)
def raster_walous_selection(
project_id: UUID,
dataset_id: UUID,
payload: ThematicRasterSelectionRequest,
db: Session = Depends(get_db),
):
return envelope(WalousLandCoverService.analyze(db, project_id, dataset_id, payload))
@router.get("/datasets/{dataset_id}/raster/walous/image")
def raster_walous_image(
project_id: UUID,
dataset_id: UUID,
db: Session = Depends(get_db),
):
content = WalousLandCoverService.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=Envelope[RasterStatsResponse],
)
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=Envelope[JobRead],
)
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=Envelope[JobRead],
)
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=Envelope[JobRead],
)
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=Envelope[JobRead],
)
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=Envelope[JobRead],
)
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=Envelope[JobRead],
)
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=Envelope[RasterMetadataResponse],
)
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=Envelope[GeoJsonFeatureCollection],
)
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))