Harden RC7 API response contracts
This commit is contained in:
@@ -661,6 +661,19 @@ bash scripts/run_readiness_check.sh
|
||||
|
||||
That readiness gate also runs the API contract smoke check before backend/frontend compilation and tests.
|
||||
|
||||
The RC contract gate loads the generated FastAPI OpenAPI document and requires
|
||||
every successful JSON operation to expose a concrete Pydantic response schema
|
||||
inside the canonical `{"data": ...}` envelope. Run it directly with:
|
||||
|
||||
```bash
|
||||
python scripts/audit_api_contracts.py
|
||||
```
|
||||
|
||||
The only tracked non-envelope operations are the three health probes, the four
|
||||
persisted raster PNG responses and the streamed export download. A newly added
|
||||
free-form JSON response or undocumented exception fails both the focused RC-7
|
||||
test and the repository readiness gate.
|
||||
|
||||
### Golden QA/QC benchmark
|
||||
|
||||
Sprint 12 includes a deterministic QA/QC regression benchmark using explicit fixture data:
|
||||
|
||||
@@ -6,6 +6,7 @@ from sqlalchemy.orm import Session
|
||||
from app.core.errors import AppError
|
||||
from app.db.session import get_db
|
||||
from app.models import Dataset
|
||||
from app.schemas import Envelope, JobRead
|
||||
from app.schemas.analysis import ChangeDetectionRequest
|
||||
from app.services.change_detection_service import ChangeDetectionService
|
||||
from app.services.job_service import JobService
|
||||
@@ -14,7 +15,7 @@ from app.utils.response import envelope
|
||||
router = APIRouter(prefix="/analysis", tags=["analysis"])
|
||||
|
||||
|
||||
@router.post("/change-detection", response_model=dict)
|
||||
@router.post("/change-detection", response_model=Envelope[JobRead])
|
||||
def run_change_detection(
|
||||
payload: ChangeDetectionRequest,
|
||||
db: Session = Depends(get_db),
|
||||
|
||||
@@ -8,14 +8,15 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.models import Area
|
||||
from app.schemas.area import AreaCreate, AreaUpdate
|
||||
from app.schemas import Envelope
|
||||
from app.schemas.area import AreaCreate, AreaList, AreaRead, AreaUpdate
|
||||
from app.services.area_service import AreaService
|
||||
from app.utils.response import envelope
|
||||
|
||||
router = APIRouter(prefix="/projects/{project_id}/areas", tags=["areas"])
|
||||
|
||||
|
||||
@router.get("", response_model=dict)
|
||||
@router.get("", response_model=Envelope[AreaList])
|
||||
def list_areas(
|
||||
project_id: UUID,
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
@@ -26,13 +27,13 @@ def list_areas(
|
||||
return envelope({"items": [AreaService.serialize_area(area) for area in areas], "total": total, "limit": limit, "offset": offset})
|
||||
|
||||
|
||||
@router.post("", status_code=201, response_model=dict)
|
||||
@router.post("", status_code=201, response_model=Envelope[AreaRead])
|
||||
def create_area(project_id: UUID, payload: AreaCreate, db: Session = Depends(get_db)):
|
||||
area = AreaService.create_area(db, project_id, payload)
|
||||
return envelope(AreaService.serialize_area(area))
|
||||
|
||||
|
||||
@router.get("/{area_id}", response_model=dict)
|
||||
@router.get("/{area_id}", response_model=Envelope[AreaRead])
|
||||
def get_area(
|
||||
project_id: UUID,
|
||||
area_id: UUID,
|
||||
@@ -44,7 +45,7 @@ def get_area(
|
||||
return envelope(AreaService.serialize_area(area))
|
||||
|
||||
|
||||
@router.patch("/{area_id}", response_model=dict)
|
||||
@router.patch("/{area_id}", response_model=Envelope[AreaRead])
|
||||
def update_area(
|
||||
project_id: UUID,
|
||||
area_id: UUID,
|
||||
|
||||
@@ -6,7 +6,13 @@ from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.schemas.assistant import AssistantQueryRequest
|
||||
from app.schemas import Envelope
|
||||
from app.schemas.assistant import (
|
||||
AssistantModelList,
|
||||
AssistantQueryRequest,
|
||||
AssistantQueryResponse,
|
||||
AssistantStatus,
|
||||
)
|
||||
from app.services.geo_assistant_service import GeoAssistantService
|
||||
from app.utils.response import envelope
|
||||
|
||||
@@ -14,12 +20,12 @@ from app.utils.response import envelope
|
||||
router = APIRouter(tags=["assistant"])
|
||||
|
||||
|
||||
@router.get("/assistant/status", response_model=dict)
|
||||
@router.get("/assistant/status", response_model=Envelope[AssistantStatus])
|
||||
def assistant_status() -> dict:
|
||||
return envelope(GeoAssistantService().status().model_dump())
|
||||
|
||||
|
||||
@router.get("/assistant/models", response_model=dict)
|
||||
@router.get("/assistant/models", response_model=Envelope[AssistantModelList])
|
||||
def assistant_models() -> dict:
|
||||
service = GeoAssistantService()
|
||||
models = service.list_models()
|
||||
@@ -32,7 +38,10 @@ def assistant_models() -> dict:
|
||||
)
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/assistant/query", response_model=dict)
|
||||
@router.post(
|
||||
"/projects/{project_id}/assistant/query",
|
||||
response_model=Envelope[AssistantQueryResponse],
|
||||
)
|
||||
def assistant_query(
|
||||
project_id: UUID,
|
||||
payload: AssistantQueryRequest,
|
||||
|
||||
@@ -13,6 +13,24 @@ 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,
|
||||
@@ -23,6 +41,7 @@ from app.schemas import (
|
||||
OrthophotoAcquireRequest,
|
||||
DhmvAcquireRequest,
|
||||
TerrainPartitionSelectionRequest,
|
||||
TerrainSelectionResponse,
|
||||
TerrainSelectionRequest,
|
||||
FloodHazardAcquireRequest,
|
||||
FloodHazardPartitionSelectionRequest,
|
||||
@@ -30,6 +49,8 @@ from app.schemas import (
|
||||
BathymetryPartitionFinalizeRequest,
|
||||
BathymetryProfileAcquireRequest,
|
||||
ThematicRasterAcquireRequest,
|
||||
ThematicRasterProductRead,
|
||||
ThematicRasterSelectionResponse,
|
||||
ThematicRasterSelectionRequest,
|
||||
GrbAcquireRequest,
|
||||
OfficialVectorAcquireRequest,
|
||||
@@ -37,12 +58,21 @@ from app.schemas import (
|
||||
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
|
||||
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
|
||||
@@ -100,7 +130,11 @@ def _run_job_sync(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/datasets/upload", status_code=201, response_model=dict)
|
||||
@router.post(
|
||||
"/datasets/upload",
|
||||
status_code=201,
|
||||
response_model=Envelope[DatasetCreateResponse],
|
||||
)
|
||||
async def upload_dataset(
|
||||
project_id: UUID,
|
||||
file: UploadFile = File(...),
|
||||
@@ -149,7 +183,7 @@ async def upload_dataset(
|
||||
return envelope(created.model_dump())
|
||||
|
||||
|
||||
@router.post("/datasets/orthophoto/acquire", response_model=dict)
|
||||
@router.post("/datasets/orthophoto/acquire", response_model=Envelope[JobRead])
|
||||
def acquire_bounded_orthophoto(
|
||||
project_id: UUID,
|
||||
payload: OrthophotoAcquireRequest,
|
||||
@@ -165,7 +199,10 @@ def acquire_bounded_orthophoto(
|
||||
return envelope(job)
|
||||
|
||||
|
||||
@router.get("/datasets/orthophoto/products", response_model=dict)
|
||||
@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)
|
||||
@@ -173,7 +210,7 @@ def list_orthophoto_products(project_id: UUID, db: Session = Depends(get_db)):
|
||||
return envelope({"items": items, "total": len(items)})
|
||||
|
||||
|
||||
@router.post("/datasets/dhmv/acquire", response_model=dict)
|
||||
@router.post("/datasets/dhmv/acquire", response_model=Envelope[JobRead])
|
||||
def acquire_bounded_dhmv(
|
||||
project_id: UUID,
|
||||
payload: DhmvAcquireRequest,
|
||||
@@ -189,7 +226,10 @@ def acquire_bounded_dhmv(
|
||||
return envelope(job)
|
||||
|
||||
|
||||
@router.get("/datasets/dhmv/products", response_model=dict)
|
||||
@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)
|
||||
@@ -197,7 +237,7 @@ def list_dhmv_products(project_id: UUID, db: Session = Depends(get_db)):
|
||||
return envelope({"items": items, "total": len(items)})
|
||||
|
||||
|
||||
@router.post("/datasets/grb/acquire", response_model=dict)
|
||||
@router.post("/datasets/grb/acquire", response_model=Envelope[JobRead])
|
||||
def acquire_bounded_grb(
|
||||
project_id: UUID,
|
||||
payload: GrbAcquireRequest,
|
||||
@@ -213,7 +253,10 @@ def acquire_bounded_grb(
|
||||
return envelope(job)
|
||||
|
||||
|
||||
@router.get("/datasets/grb/products", response_model=dict)
|
||||
@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)
|
||||
@@ -221,7 +264,7 @@ def list_grb_products(project_id: UUID, db: Session = Depends(get_db)):
|
||||
return envelope({"items": items, "total": len(items)})
|
||||
|
||||
|
||||
@router.post("/datasets/official-vector/acquire", response_model=dict)
|
||||
@router.post("/datasets/official-vector/acquire", response_model=Envelope[JobRead])
|
||||
def acquire_bounded_official_vector(
|
||||
project_id: UUID,
|
||||
payload: OfficialVectorAcquireRequest,
|
||||
@@ -237,7 +280,10 @@ def acquire_bounded_official_vector(
|
||||
return envelope(job)
|
||||
|
||||
|
||||
@router.get("/datasets/official-vector/products", response_model=dict)
|
||||
@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)
|
||||
@@ -245,7 +291,7 @@ def list_official_vector_products(project_id: UUID, db: Session = Depends(get_db
|
||||
return envelope({"items": items, "total": len(items)})
|
||||
|
||||
|
||||
@router.post("/datasets/flood-hazard/acquire", response_model=dict)
|
||||
@router.post("/datasets/flood-hazard/acquire", response_model=Envelope[JobRead])
|
||||
def acquire_bounded_flood_hazard(
|
||||
project_id: UUID,
|
||||
payload: FloodHazardAcquireRequest,
|
||||
@@ -261,7 +307,10 @@ def acquire_bounded_flood_hazard(
|
||||
return envelope(job)
|
||||
|
||||
|
||||
@router.get("/datasets/flood-hazard/products", response_model=dict)
|
||||
@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)
|
||||
@@ -269,7 +318,10 @@ def list_flood_hazard_products(project_id: UUID, db: Session = Depends(get_db)):
|
||||
return envelope({"items": items, "total": len(items)})
|
||||
|
||||
|
||||
@router.get("/datasets/bathymetry/sources", response_model=dict)
|
||||
@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)
|
||||
@@ -277,14 +329,20 @@ def list_bathymetry_sources(project_id: UUID, db: Session = Depends(get_db)):
|
||||
return envelope({"items": items, "total": len(items)})
|
||||
|
||||
|
||||
@router.get("/datasets/bathymetry/sources/mdk_bcp_bathymetry/readiness", response_model=dict)
|
||||
@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/profiles/acquire", response_model=dict)
|
||||
@router.post(
|
||||
"/datasets/bathymetry/profiles/acquire",
|
||||
response_model=Envelope[JobRead],
|
||||
)
|
||||
def acquire_bounded_bathymetry_profiles(
|
||||
project_id: UUID,
|
||||
payload: BathymetryProfileAcquireRequest,
|
||||
@@ -300,7 +358,10 @@ def acquire_bounded_bathymetry_profiles(
|
||||
return envelope(job)
|
||||
|
||||
|
||||
@router.post("/datasets/bathymetry/profiles/partitions/finalize", response_model=dict)
|
||||
@router.post(
|
||||
"/datasets/bathymetry/profiles/partitions/finalize",
|
||||
response_model=Envelope[BathymetryPartitionFinalizationResult],
|
||||
)
|
||||
def finalize_bathymetry_profile_partitions(
|
||||
project_id: UUID,
|
||||
payload: BathymetryPartitionFinalizeRequest,
|
||||
@@ -309,7 +370,10 @@ def finalize_bathymetry_profile_partitions(
|
||||
return envelope(BathymetryProfileAcquisitionService.finalize_partitions(db, project_id, payload))
|
||||
|
||||
|
||||
@router.post("/datasets/bathymetry/profiles/partitions/select", response_model=dict)
|
||||
@router.post(
|
||||
"/datasets/bathymetry/profiles/partitions/select",
|
||||
response_model=Envelope[VectorSelectionResponse],
|
||||
)
|
||||
def select_bathymetry_profile_partitions(
|
||||
project_id: UUID,
|
||||
payload: VectorSelectionRequest,
|
||||
@@ -344,7 +408,7 @@ def select_bathymetry_profile_partitions(
|
||||
return envelope(VectorSelectionResponse(**result).model_dump(exclude_none=True))
|
||||
|
||||
|
||||
@router.post("/datasets/thematic-raster/acquire", response_model=dict)
|
||||
@router.post("/datasets/thematic-raster/acquire", response_model=Envelope[JobRead])
|
||||
def acquire_bounded_thematic_raster(
|
||||
project_id: UUID,
|
||||
payload: ThematicRasterAcquireRequest,
|
||||
@@ -360,7 +424,10 @@ def acquire_bounded_thematic_raster(
|
||||
return envelope(job)
|
||||
|
||||
|
||||
@router.get("/datasets/thematic-raster/products", response_model=dict)
|
||||
@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)
|
||||
@@ -368,7 +435,7 @@ def list_thematic_raster_products(project_id: UUID, db: Session = Depends(get_db
|
||||
return envelope({"items": items, "total": len(items)})
|
||||
|
||||
|
||||
@router.get("/datasets", response_model=dict)
|
||||
@router.get("/datasets", response_model=Envelope[DatasetList])
|
||||
def list_datasets(
|
||||
project_id: UUID,
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
@@ -379,7 +446,10 @@ def list_datasets(
|
||||
return envelope({"items": [item.model_dump() for item in datasets], "total": total, "limit": limit, "offset": offset})
|
||||
|
||||
|
||||
@router.get("/datasets/source-freshness", response_model=dict)
|
||||
@router.get(
|
||||
"/datasets/source-freshness",
|
||||
response_model=Envelope[SourceFreshnessReport],
|
||||
)
|
||||
def audit_dataset_source_freshness(
|
||||
project_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
@@ -388,7 +458,10 @@ def audit_dataset_source_freshness(
|
||||
return envelope(report.model_dump())
|
||||
|
||||
|
||||
@router.get("/datasets/source-catalog-probes", response_model=dict)
|
||||
@router.get(
|
||||
"/datasets/source-catalog-probes",
|
||||
response_model=Envelope[SourceCatalogProbeReport],
|
||||
)
|
||||
def probe_dataset_source_catalogs(
|
||||
project_id: UUID,
|
||||
refresh: bool = Query(default=False),
|
||||
@@ -398,7 +471,10 @@ def probe_dataset_source_catalogs(
|
||||
return envelope(report.model_dump())
|
||||
|
||||
|
||||
@router.get("/datasets/grb-refresh-plan", response_model=dict)
|
||||
@router.get(
|
||||
"/datasets/grb-refresh-plan",
|
||||
response_model=Envelope[GrbRefreshPlan],
|
||||
)
|
||||
def plan_grb_dataset_refresh(
|
||||
project_id: UUID,
|
||||
scope: str = Query(default=GrbRefreshPlanService.SCOPE),
|
||||
@@ -414,7 +490,7 @@ def plan_grb_dataset_refresh(
|
||||
return envelope(report.model_dump())
|
||||
|
||||
|
||||
@router.get("/datasets/{dataset_id}", response_model=dict)
|
||||
@router.get("/datasets/{dataset_id}", response_model=Envelope[DatasetCreateResponse])
|
||||
def get_dataset(
|
||||
project_id: UUID,
|
||||
dataset_id: UUID,
|
||||
@@ -426,7 +502,10 @@ def get_dataset(
|
||||
return envelope(DatasetCreateResponse.model_validate(dataset).model_dump())
|
||||
|
||||
|
||||
@router.patch("/datasets/{dataset_id}/temporal", response_model=dict)
|
||||
@router.patch(
|
||||
"/datasets/{dataset_id}/temporal",
|
||||
response_model=Envelope[DatasetCreateResponse],
|
||||
)
|
||||
def update_dataset_temporal_metadata(
|
||||
project_id: UUID,
|
||||
dataset_id: UUID,
|
||||
@@ -440,7 +519,10 @@ def update_dataset_temporal_metadata(
|
||||
return envelope(updated.model_dump())
|
||||
|
||||
|
||||
@router.get("/datasets/{dataset_id}/versions", response_model=dict)
|
||||
@router.get(
|
||||
"/datasets/{dataset_id}/versions",
|
||||
response_model=Envelope[ItemList[DatasetVersionRead]],
|
||||
)
|
||||
def list_dataset_versions(
|
||||
project_id: UUID,
|
||||
dataset_id: UUID,
|
||||
@@ -453,7 +535,10 @@ def list_dataset_versions(
|
||||
return envelope({"items": [item.model_dump() for item in versions], "total": len(versions)})
|
||||
|
||||
|
||||
@router.post("/datasets/{dataset_id}/metadata/refresh", response_model=dict)
|
||||
@router.post(
|
||||
"/datasets/{dataset_id}/metadata/refresh",
|
||||
response_model=Envelope[DatasetCreateResponse],
|
||||
)
|
||||
def refresh_dataset_metadata(
|
||||
project_id: UUID,
|
||||
dataset_id: UUID,
|
||||
@@ -466,7 +551,10 @@ def refresh_dataset_metadata(
|
||||
return envelope(refreshed.model_dump())
|
||||
|
||||
|
||||
@router.get("/datasets/{dataset_id}/vector/inspect", response_model=dict)
|
||||
@router.get(
|
||||
"/datasets/{dataset_id}/vector/inspect",
|
||||
response_model=Envelope[VectorOperationResult],
|
||||
)
|
||||
def inspect_vector_dataset(
|
||||
project_id: UUID,
|
||||
dataset_id: UUID,
|
||||
@@ -478,7 +566,10 @@ def inspect_vector_dataset(
|
||||
return envelope(VectorOperationsService.inspect(db, dataset_id).model_dump())
|
||||
|
||||
|
||||
@router.get("/datasets/{dataset_id}/vector/bbox", response_model=dict)
|
||||
@router.get(
|
||||
"/datasets/{dataset_id}/vector/bbox",
|
||||
response_model=Envelope[VectorBBoxResponse],
|
||||
)
|
||||
def vector_bbox(
|
||||
project_id: UUID,
|
||||
dataset_id: UUID,
|
||||
@@ -491,7 +582,10 @@ def vector_bbox(
|
||||
return envelope(VectorBBoxResponse(**payload).model_dump())
|
||||
|
||||
|
||||
@router.get("/datasets/{dataset_id}/vector/stats", response_model=dict)
|
||||
@router.get(
|
||||
"/datasets/{dataset_id}/vector/stats",
|
||||
response_model=Envelope[VectorStatsResponse],
|
||||
)
|
||||
def vector_stats(
|
||||
project_id: UUID,
|
||||
dataset_id: UUID,
|
||||
@@ -503,7 +597,10 @@ def vector_stats(
|
||||
return envelope(VectorOperationsService.stats(db, dataset_id))
|
||||
|
||||
|
||||
@router.post("/datasets/{dataset_id}/vector/select", response_model=dict)
|
||||
@router.post(
|
||||
"/datasets/{dataset_id}/vector/select",
|
||||
response_model=Envelope[VectorSelectionResponse],
|
||||
)
|
||||
def select_vector_features(
|
||||
project_id: UUID,
|
||||
dataset_id: UUID,
|
||||
@@ -562,7 +659,11 @@ def select_vector_features(
|
||||
return envelope(VectorSelectionResponse(**result).model_dump(exclude_none=True))
|
||||
|
||||
|
||||
@router.post("/datasets/{dataset_id}/vector/select/derive", status_code=201, response_model=dict)
|
||||
@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,
|
||||
@@ -597,7 +698,11 @@ def derive_vector_selection_dataset(
|
||||
return envelope(derived.model_dump())
|
||||
|
||||
|
||||
@router.post("/datasets/{dataset_id}/vector/clip", status_code=201, response_model=dict)
|
||||
@router.post(
|
||||
"/datasets/{dataset_id}/vector/clip",
|
||||
status_code=201,
|
||||
response_model=Envelope[JobRead],
|
||||
)
|
||||
def clip_vector_dataset(
|
||||
project_id: UUID,
|
||||
dataset_id: UUID,
|
||||
@@ -623,7 +728,11 @@ def clip_vector_dataset(
|
||||
return envelope(job)
|
||||
|
||||
|
||||
@router.post("/datasets/{dataset_id}/vector/buffer", status_code=201, response_model=dict)
|
||||
@router.post(
|
||||
"/datasets/{dataset_id}/vector/buffer",
|
||||
status_code=201,
|
||||
response_model=Envelope[JobRead],
|
||||
)
|
||||
def buffer_vector_dataset(
|
||||
project_id: UUID,
|
||||
dataset_id: UUID,
|
||||
@@ -650,7 +759,11 @@ def buffer_vector_dataset(
|
||||
return envelope(job)
|
||||
|
||||
|
||||
@router.post("/datasets/{dataset_id}/vector/intersect", status_code=201, response_model=dict)
|
||||
@router.post(
|
||||
"/datasets/{dataset_id}/vector/intersect",
|
||||
status_code=201,
|
||||
response_model=Envelope[JobRead],
|
||||
)
|
||||
def intersect_vector_dataset(
|
||||
project_id: UUID,
|
||||
dataset_id: UUID,
|
||||
@@ -676,7 +789,10 @@ def intersect_vector_dataset(
|
||||
return envelope(job)
|
||||
|
||||
|
||||
@router.get("/datasets/{dataset_id}/vector/summary", response_model=dict)
|
||||
@router.get(
|
||||
"/datasets/{dataset_id}/vector/summary",
|
||||
response_model=Envelope[DatasetVectorSummary],
|
||||
)
|
||||
def vector_dataset_summary(
|
||||
project_id: UUID,
|
||||
dataset_id: UUID,
|
||||
@@ -688,7 +804,10 @@ def vector_dataset_summary(
|
||||
return envelope(DatasetService.vector_summary(db, dataset_id))
|
||||
|
||||
|
||||
@router.get("/datasets/{dataset_id}/raster/inspect", response_model=dict)
|
||||
@router.get(
|
||||
"/datasets/{dataset_id}/raster/inspect",
|
||||
response_model=Envelope[RasterOperationResult],
|
||||
)
|
||||
def raster_dataset_inspect(
|
||||
project_id: UUID,
|
||||
dataset_id: UUID,
|
||||
@@ -701,7 +820,10 @@ def raster_dataset_inspect(
|
||||
return envelope(payload)
|
||||
|
||||
|
||||
@router.get("/datasets/{dataset_id}/raster/preview", response_model=dict)
|
||||
@router.get(
|
||||
"/datasets/{dataset_id}/raster/preview",
|
||||
response_model=Envelope[RasterPreviewResponse],
|
||||
)
|
||||
def raster_preview_readiness(
|
||||
project_id: UUID,
|
||||
dataset_id: UUID,
|
||||
@@ -727,7 +849,10 @@ def raster_orthophoto_image(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/datasets/{dataset_id}/raster/terrain/select", response_model=dict)
|
||||
@router.post(
|
||||
"/datasets/{dataset_id}/raster/terrain/select",
|
||||
response_model=Envelope[TerrainSelectionResponse],
|
||||
)
|
||||
def raster_terrain_selection(
|
||||
project_id: UUID,
|
||||
dataset_id: UUID,
|
||||
@@ -737,7 +862,10 @@ def raster_terrain_selection(
|
||||
return envelope(TerrainAnalysisService.analyze(db, project_id, dataset_id, payload))
|
||||
|
||||
|
||||
@router.post("/datasets/raster/terrain/select", response_model=dict)
|
||||
@router.post(
|
||||
"/datasets/raster/terrain/select",
|
||||
response_model=Envelope[TerrainSelectionResponse],
|
||||
)
|
||||
def partitioned_raster_terrain_selection(
|
||||
project_id: UUID,
|
||||
payload: TerrainPartitionSelectionRequest,
|
||||
@@ -760,7 +888,10 @@ def raster_terrain_image(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/datasets/{dataset_id}/raster/flood-hazard/select", response_model=dict)
|
||||
@router.post(
|
||||
"/datasets/{dataset_id}/raster/flood-hazard/select",
|
||||
response_model=Envelope[FloodHazardSelectionResponse],
|
||||
)
|
||||
def raster_flood_hazard_selection(
|
||||
project_id: UUID,
|
||||
dataset_id: UUID,
|
||||
@@ -770,7 +901,10 @@ def raster_flood_hazard_selection(
|
||||
return envelope(FloodHazardAnalysisService.analyze(db, project_id, dataset_id, payload))
|
||||
|
||||
|
||||
@router.post("/datasets/raster/flood-hazard/select", response_model=dict)
|
||||
@router.post(
|
||||
"/datasets/raster/flood-hazard/select",
|
||||
response_model=Envelope[FloodHazardSelectionResponse],
|
||||
)
|
||||
def partitioned_raster_flood_hazard_selection(
|
||||
project_id: UUID,
|
||||
payload: FloodHazardPartitionSelectionRequest,
|
||||
@@ -793,7 +927,10 @@ def raster_flood_hazard_image(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/datasets/{dataset_id}/raster/thematic/select", response_model=dict)
|
||||
@router.post(
|
||||
"/datasets/{dataset_id}/raster/thematic/select",
|
||||
response_model=Envelope[ThematicRasterSelectionResponse],
|
||||
)
|
||||
def raster_thematic_selection(
|
||||
project_id: UUID,
|
||||
dataset_id: UUID,
|
||||
@@ -817,7 +954,10 @@ def raster_thematic_image(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/datasets/{dataset_id}/raster/stats", response_model=dict)
|
||||
@router.get(
|
||||
"/datasets/{dataset_id}/raster/stats",
|
||||
response_model=Envelope[RasterStatsResponse],
|
||||
)
|
||||
def raster_stats(
|
||||
project_id: UUID,
|
||||
dataset_id: UUID,
|
||||
@@ -830,7 +970,11 @@ def raster_stats(
|
||||
return envelope(RasterStatsResponse(**payload).model_dump())
|
||||
|
||||
|
||||
@router.post("/datasets/{dataset_id}/raster/reproject", status_code=201, response_model=dict)
|
||||
@router.post(
|
||||
"/datasets/{dataset_id}/raster/reproject",
|
||||
status_code=201,
|
||||
response_model=Envelope[JobRead],
|
||||
)
|
||||
def raster_reproject_dataset(
|
||||
project_id: UUID,
|
||||
dataset_id: UUID,
|
||||
@@ -857,7 +1001,11 @@ def raster_reproject_dataset(
|
||||
return envelope(job)
|
||||
|
||||
|
||||
@router.post("/datasets/{dataset_id}/raster/clip", status_code=201, response_model=dict)
|
||||
@router.post(
|
||||
"/datasets/{dataset_id}/raster/clip",
|
||||
status_code=201,
|
||||
response_model=Envelope[JobRead],
|
||||
)
|
||||
def raster_clip_dataset(
|
||||
project_id: UUID,
|
||||
dataset_id: UUID,
|
||||
@@ -878,7 +1026,11 @@ def raster_clip_dataset(
|
||||
return envelope(job)
|
||||
|
||||
|
||||
@router.post("/datasets/{dataset_id}/raster/tile", status_code=201, response_model=dict)
|
||||
@router.post(
|
||||
"/datasets/{dataset_id}/raster/tile",
|
||||
status_code=201,
|
||||
response_model=Envelope[JobRead],
|
||||
)
|
||||
def raster_tile_dataset(
|
||||
project_id: UUID,
|
||||
dataset_id: UUID,
|
||||
@@ -905,7 +1057,11 @@ def raster_tile_dataset(
|
||||
return envelope(job)
|
||||
|
||||
|
||||
@router.post("/datasets/{dataset_id}/raster/indices/ndvi", status_code=201, response_model=dict)
|
||||
@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,
|
||||
@@ -932,7 +1088,11 @@ def raster_ndvi_dataset(
|
||||
return envelope(job)
|
||||
|
||||
|
||||
@router.post("/datasets/{dataset_id}/raster/indices/ndwi", status_code=201, response_model=dict)
|
||||
@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,
|
||||
@@ -959,7 +1119,11 @@ def raster_ndwi_dataset(
|
||||
return envelope(job)
|
||||
|
||||
|
||||
@router.post("/datasets/{dataset_id}/raster/indices/ndbi", status_code=201, response_model=dict)
|
||||
@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,
|
||||
@@ -986,7 +1150,10 @@ def raster_ndbi_dataset(
|
||||
return envelope(job)
|
||||
|
||||
|
||||
@router.get("/datasets/{dataset_id}/raster/metadata", response_model=dict)
|
||||
@router.get(
|
||||
"/datasets/{dataset_id}/raster/metadata",
|
||||
response_model=Envelope[RasterMetadataResponse],
|
||||
)
|
||||
def raster_dataset_metadata(
|
||||
project_id: UUID,
|
||||
dataset_id: UUID,
|
||||
@@ -998,7 +1165,10 @@ def raster_dataset_metadata(
|
||||
return envelope(RasterOperationsService.metadata(db, dataset_id))
|
||||
|
||||
|
||||
@router.get("/datasets/{dataset_id}/content", response_model=dict)
|
||||
@router.get(
|
||||
"/datasets/{dataset_id}/content",
|
||||
response_model=Envelope[GeoJsonFeatureCollection],
|
||||
)
|
||||
def dataset_content(
|
||||
project_id: UUID,
|
||||
dataset_id: UUID,
|
||||
|
||||
@@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.schemas import Envelope
|
||||
from app.schemas.demo import DemoWorkflowResponse
|
||||
from app.services.demo_workflow_service import DemoWorkflowService
|
||||
from app.utils.response import envelope
|
||||
@@ -11,7 +12,11 @@ from app.utils.response import envelope
|
||||
router = APIRouter(prefix="/demo", tags=["demo"])
|
||||
|
||||
|
||||
@router.post("/workflow", status_code=status.HTTP_201_CREATED, response_model=dict)
|
||||
@router.post(
|
||||
"/workflow",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
response_model=Envelope[DemoWorkflowResponse],
|
||||
)
|
||||
def seed_demo_workflow(db: Session = Depends(get_db)) -> dict:
|
||||
result: DemoWorkflowResponse = DemoWorkflowService.seed(db)
|
||||
return envelope(result.model_dump())
|
||||
|
||||
@@ -6,7 +6,21 @@ from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.schemas import DetectionQaRequest, DetectionRunRequest
|
||||
from app.schemas import (
|
||||
AnalysisQaResponse,
|
||||
DetectionListResponse,
|
||||
DetectionModelsResponse,
|
||||
DetectionQaRequest,
|
||||
DetectionRead,
|
||||
DetectionRunListResponse,
|
||||
DetectionRunRead,
|
||||
DetectionRunRequest,
|
||||
DetectionRunResponse,
|
||||
Envelope,
|
||||
GeoJsonFeatureCollection,
|
||||
ModelAssetListResponse,
|
||||
YoloPreflightResponse,
|
||||
)
|
||||
from app.services.detection_service import DetectionService
|
||||
from app.services.model_asset_catalog_service import ModelAssetCatalogService
|
||||
from app.services.model_registry_service import ModelRegistryService
|
||||
@@ -16,17 +30,17 @@ from app.utils.response import envelope
|
||||
router = APIRouter(prefix="/detection", tags=["detection"])
|
||||
|
||||
|
||||
@router.get("/models", response_model=dict)
|
||||
@router.get("/models", response_model=Envelope[DetectionModelsResponse])
|
||||
def list_detection_models() -> dict:
|
||||
return envelope({"models": [model.model_dump() for model in ModelRegistryService.list_model_capabilities()]})
|
||||
|
||||
|
||||
@router.get("/model-assets", response_model=dict)
|
||||
@router.get("/model-assets", response_model=Envelope[ModelAssetListResponse])
|
||||
def list_detection_model_assets() -> dict:
|
||||
return envelope(ModelAssetCatalogService.list_assets().model_dump())
|
||||
|
||||
|
||||
@router.get("/yolo/preflight", response_model=dict)
|
||||
@router.get("/yolo/preflight", response_model=Envelope[YoloPreflightResponse])
|
||||
def get_yolo_preflight(
|
||||
tile_manifest_path: str | None = None,
|
||||
check_model_load: bool = False,
|
||||
@@ -41,7 +55,7 @@ def get_yolo_preflight(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/run", response_model=dict)
|
||||
@router.post("/run", response_model=Envelope[DetectionRunResponse])
|
||||
def run_detection(payload: DetectionRunRequest, db: Session = Depends(get_db)) -> dict:
|
||||
result = DetectionService.run_detection(
|
||||
db=db,
|
||||
@@ -57,7 +71,7 @@ def run_detection(payload: DetectionRunRequest, db: Session = Depends(get_db)) -
|
||||
return envelope(result.model_dump())
|
||||
|
||||
|
||||
@router.get("/runs", response_model=dict)
|
||||
@router.get("/runs", response_model=Envelope[DetectionRunListResponse])
|
||||
def list_detection_runs(
|
||||
project_id: UUID | None = None,
|
||||
dataset_id: UUID | None = None,
|
||||
@@ -66,12 +80,15 @@ def list_detection_runs(
|
||||
return envelope(DetectionService.list_runs(db, project_id=project_id, dataset_id=dataset_id).model_dump())
|
||||
|
||||
|
||||
@router.get("/runs/{analysis_run_id}", response_model=dict)
|
||||
@router.get("/runs/{analysis_run_id}", response_model=Envelope[DetectionRunRead])
|
||||
def get_detection_run(analysis_run_id: UUID, db: Session = Depends(get_db)) -> dict:
|
||||
return envelope(DetectionService.get_run(db, analysis_run_id).model_dump())
|
||||
|
||||
|
||||
@router.get("/runs/{analysis_run_id}/detections", response_model=dict)
|
||||
@router.get(
|
||||
"/runs/{analysis_run_id}/detections",
|
||||
response_model=Envelope[DetectionListResponse],
|
||||
)
|
||||
def list_detection_run_detections(
|
||||
analysis_run_id: UUID,
|
||||
dataset_id: UUID | None = None,
|
||||
@@ -90,7 +107,10 @@ def list_detection_run_detections(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/datasets/{dataset_id}/detections", response_model=dict)
|
||||
@router.get(
|
||||
"/datasets/{dataset_id}/detections",
|
||||
response_model=Envelope[DetectionListResponse],
|
||||
)
|
||||
def list_dataset_detections(
|
||||
dataset_id: UUID,
|
||||
analysis_run_id: UUID | None = None,
|
||||
@@ -109,12 +129,15 @@ def list_dataset_detections(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/detections/{detection_id}", response_model=dict)
|
||||
@router.get("/detections/{detection_id}", response_model=Envelope[DetectionRead])
|
||||
def get_detection(detection_id: UUID, db: Session = Depends(get_db)) -> dict:
|
||||
return envelope(DetectionService.get_detection(db, detection_id).model_dump())
|
||||
|
||||
|
||||
@router.get("/runs/{analysis_run_id}/geojson", response_model=dict)
|
||||
@router.get(
|
||||
"/runs/{analysis_run_id}/geojson",
|
||||
response_model=Envelope[GeoJsonFeatureCollection],
|
||||
)
|
||||
def get_detection_run_geojson(
|
||||
analysis_run_id: UUID,
|
||||
class_name: str | None = None,
|
||||
@@ -131,7 +154,10 @@ def get_detection_run_geojson(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/datasets/{dataset_id}/geojson", response_model=dict)
|
||||
@router.get(
|
||||
"/datasets/{dataset_id}/geojson",
|
||||
response_model=Envelope[GeoJsonFeatureCollection],
|
||||
)
|
||||
def get_dataset_detection_geojson(
|
||||
dataset_id: UUID,
|
||||
analysis_run_id: UUID | None = None,
|
||||
@@ -150,7 +176,10 @@ def get_dataset_detection_geojson(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/runs/{analysis_run_id}/qa/reference", response_model=dict)
|
||||
@router.post(
|
||||
"/runs/{analysis_run_id}/qa/reference",
|
||||
response_model=Envelope[AnalysisQaResponse],
|
||||
)
|
||||
def compare_detection_run_with_reference(
|
||||
analysis_run_id: UUID,
|
||||
payload: DetectionQaRequest,
|
||||
|
||||
@@ -7,14 +7,24 @@ from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.schemas.export import GeoJsonExportRequest, MapResultExportRequest, MetadataExportRequest, ReportExportRequest
|
||||
from app.schemas import Envelope
|
||||
from app.schemas.export import (
|
||||
ExportContentResponse,
|
||||
ExportCreateResponse,
|
||||
ExportListResponse,
|
||||
ExportRead,
|
||||
GeoJsonExportRequest,
|
||||
MapResultExportRequest,
|
||||
MetadataExportRequest,
|
||||
ReportExportRequest,
|
||||
)
|
||||
from app.services.export_service import ExportService
|
||||
from app.utils.response import envelope
|
||||
|
||||
router = APIRouter(prefix="/exports", tags=["exports"])
|
||||
|
||||
|
||||
@router.post("/geojson", response_model=dict)
|
||||
@router.post("/geojson", response_model=Envelope[ExportCreateResponse])
|
||||
def export_geojson(payload: GeoJsonExportRequest, db: Session = Depends(get_db)):
|
||||
if payload.export_kind == "vector_selection" and payload.dataset_id is not None and payload.bbox is not None:
|
||||
return envelope(
|
||||
@@ -40,22 +50,25 @@ def export_geojson(payload: GeoJsonExportRequest, db: Session = Depends(get_db))
|
||||
return envelope({})
|
||||
|
||||
|
||||
@router.post("/metadata", response_model=dict)
|
||||
@router.post("/metadata", response_model=Envelope[ExportCreateResponse])
|
||||
def export_project_metadata(payload: MetadataExportRequest, db: Session = Depends(get_db)):
|
||||
return envelope(ExportService.export_project_metadata(db, payload.project_id, payload.name).model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.post("/report", response_model=dict)
|
||||
@router.post("/report", response_model=Envelope[ExportCreateResponse])
|
||||
def export_project_report(payload: ReportExportRequest, db: Session = Depends(get_db)):
|
||||
return envelope(ExportService.export_project_report(db, payload.project_id, payload.name).model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.post("/map-result", response_model=dict)
|
||||
@router.post("/map-result", response_model=Envelope[ExportCreateResponse])
|
||||
def export_map_result(payload: MapResultExportRequest, db: Session = Depends(get_db)):
|
||||
return envelope(ExportService.export_map_result(db, payload).model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/exports", response_model=dict)
|
||||
@router.get(
|
||||
"/projects/{project_id}/exports",
|
||||
response_model=Envelope[ExportListResponse],
|
||||
)
|
||||
def list_project_exports(
|
||||
project_id: UUID,
|
||||
limit: int = Query(default=50, ge=1, le=100),
|
||||
@@ -65,7 +78,7 @@ def list_project_exports(
|
||||
return envelope(ExportService.list_project_exports(db, project_id, limit=limit, offset=offset).model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.get("/{export_id}", response_model=dict)
|
||||
@router.get("/{export_id}", response_model=Envelope[ExportRead])
|
||||
def get_export(export_id: UUID, db: Session = Depends(get_db)):
|
||||
return envelope(ExportService.get_export(db, export_id).model_dump(mode="json"))
|
||||
|
||||
@@ -77,6 +90,6 @@ def download_export(export_id: UUID, db: Session = Depends(get_db)):
|
||||
return FileResponse(path, filename=path.name, media_type=media_type)
|
||||
|
||||
|
||||
@router.get("/{export_id}/content", response_model=dict)
|
||||
@router.get("/{export_id}/content", response_model=Envelope[ExportContentResponse])
|
||||
def get_export_content(export_id: UUID, db: Session = Depends(get_db)):
|
||||
return envelope(ExportService.get_export_content(db, export_id).model_dump(mode="json"))
|
||||
|
||||
@@ -7,7 +7,20 @@ from app.core.errors import AppError
|
||||
from app.db.session import get_db
|
||||
from app.models import Area, Project
|
||||
from app.providers.registry import fetch_provider_data, get_provider, import_provider_dataset, list_provider_capabilities
|
||||
from app.schemas import CoverageResolveRequest, ExternalFetchRequest, ExternalFetchResponse, ProviderImportRequest
|
||||
from app.schemas import (
|
||||
CoverageCatalogResponse,
|
||||
CoverageResolveRequest,
|
||||
CoverageResolveResponse,
|
||||
Envelope,
|
||||
ExternalFetchRequest,
|
||||
ExternalFetchResponse,
|
||||
ProviderCapabilitiesResponse,
|
||||
ProviderCapabilityResponse,
|
||||
ProviderImportRequest,
|
||||
ProviderImportResponse,
|
||||
ProviderLayersResponse,
|
||||
ProviderStatusResponse,
|
||||
)
|
||||
from app.services.coverage_registry_service import CoverageRegistryService
|
||||
from app.utils.response import envelope
|
||||
|
||||
@@ -40,19 +53,19 @@ def _provider_payload(provider_name: str) -> dict:
|
||||
return get_provider(provider_name).capability.to_dict()
|
||||
|
||||
|
||||
@router.get("/providers")
|
||||
@router.get("/providers", response_model=Envelope[ProviderCapabilitiesResponse])
|
||||
def list_external_providers() -> dict:
|
||||
return envelope({
|
||||
"providers": [provider.to_dict() for provider in list_provider_capabilities()],
|
||||
})
|
||||
|
||||
|
||||
@router.get("/coverage/catalog")
|
||||
@router.get("/coverage/catalog", response_model=Envelope[CoverageCatalogResponse])
|
||||
def get_coverage_catalog() -> dict:
|
||||
return envelope(CoverageRegistryService.catalog().model_dump())
|
||||
|
||||
|
||||
@router.post("/coverage/resolve")
|
||||
@router.post("/coverage/resolve", response_model=Envelope[CoverageResolveResponse])
|
||||
def resolve_project_coverage(payload: CoverageResolveRequest, db: Session = Depends(get_db)) -> dict:
|
||||
result = CoverageRegistryService.resolve(
|
||||
db,
|
||||
@@ -63,19 +76,28 @@ def resolve_project_coverage(payload: CoverageResolveRequest, db: Session = Depe
|
||||
return envelope(result.model_dump())
|
||||
|
||||
|
||||
@router.get("/providers/capabilities")
|
||||
@router.get(
|
||||
"/providers/capabilities",
|
||||
response_model=Envelope[ProviderCapabilitiesResponse],
|
||||
)
|
||||
def get_external_provider_capabilities() -> dict:
|
||||
return envelope({
|
||||
"providers": [provider.to_dict() for provider in list_provider_capabilities()],
|
||||
})
|
||||
|
||||
|
||||
@router.get("/providers/{provider_name}")
|
||||
@router.get(
|
||||
"/providers/{provider_name}",
|
||||
response_model=Envelope[ProviderCapabilityResponse],
|
||||
)
|
||||
def get_external_provider(provider_name: str) -> dict:
|
||||
return envelope(_provider_payload(provider_name))
|
||||
|
||||
|
||||
@router.get("/providers/{provider_name}/layers")
|
||||
@router.get(
|
||||
"/providers/{provider_name}/layers",
|
||||
response_model=Envelope[ProviderLayersResponse],
|
||||
)
|
||||
def get_external_provider_layers(provider_name: str) -> dict:
|
||||
provider = get_provider(provider_name)
|
||||
return envelope({
|
||||
@@ -84,7 +106,10 @@ def get_external_provider_layers(provider_name: str) -> dict:
|
||||
})
|
||||
|
||||
|
||||
@router.get("/providers/{provider_name}/status")
|
||||
@router.get(
|
||||
"/providers/{provider_name}/status",
|
||||
response_model=Envelope[ProviderStatusResponse],
|
||||
)
|
||||
def get_external_provider_status(provider_name: str) -> dict:
|
||||
provider = get_provider(provider_name)
|
||||
return envelope({
|
||||
@@ -95,7 +120,10 @@ def get_external_provider_status(provider_name: str) -> dict:
|
||||
})
|
||||
|
||||
|
||||
@router.post("/providers/{provider_name}/import")
|
||||
@router.post(
|
||||
"/providers/{provider_name}/import",
|
||||
response_model=Envelope[ProviderImportResponse],
|
||||
)
|
||||
def import_external_provider_dataset(provider_name: str, payload: ProviderImportRequest) -> dict:
|
||||
result = import_provider_dataset(
|
||||
provider_name=provider_name,
|
||||
@@ -125,14 +153,14 @@ def _run_fetch(payload: ExternalFetchRequest, provider_name: str) -> ExternalFet
|
||||
)
|
||||
|
||||
|
||||
@router.post("/osm/fetch")
|
||||
@router.post("/osm/fetch", response_model=Envelope[ExternalFetchResponse])
|
||||
def fetch_osm(payload: ExternalFetchRequest, db: Session = Depends(get_db)) -> dict:
|
||||
_assert_project_exists(db, payload.project_id)
|
||||
_validate_area_in_project(db, payload.project_id, payload.area_id)
|
||||
return envelope(_run_fetch(payload, "osm").model_dump())
|
||||
|
||||
|
||||
@router.post("/grb/fetch")
|
||||
@router.post("/grb/fetch", response_model=Envelope[ExternalFetchResponse])
|
||||
def fetch_grb(payload: ExternalFetchRequest, db: Session = Depends(get_db)) -> dict:
|
||||
_assert_project_exists(db, payload.project_id)
|
||||
_validate_area_in_project(db, payload.project_id, payload.area_id)
|
||||
|
||||
@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.schemas import JobCreate, JobList, JobRead, JobStatus
|
||||
from app.schemas import Envelope, JobCreate, JobList, JobRead, JobStatus
|
||||
from app.services.job_service import JobService
|
||||
from app.utils.response import envelope
|
||||
|
||||
@@ -14,7 +14,7 @@ from app.utils.response import envelope
|
||||
router = APIRouter(prefix="/projects/{project_id}", tags=["jobs"])
|
||||
|
||||
|
||||
@router.post("/jobs", status_code=201, response_model=dict)
|
||||
@router.post("/jobs", status_code=201, response_model=Envelope[JobRead])
|
||||
def create_job(
|
||||
project_id: UUID,
|
||||
payload: JobCreate,
|
||||
@@ -25,7 +25,7 @@ def create_job(
|
||||
return envelope(JobService.create_job(db, payload).model_dump())
|
||||
|
||||
|
||||
@router.get("/jobs", response_model=dict)
|
||||
@router.get("/jobs", response_model=Envelope[JobList])
|
||||
def list_jobs(
|
||||
project_id: UUID,
|
||||
dataset_id: UUID | None = Query(default=None),
|
||||
@@ -43,7 +43,7 @@ def list_jobs(
|
||||
return envelope(JobList(items=items, total=total, limit=limit, offset=offset).model_dump())
|
||||
|
||||
|
||||
@router.get("/jobs/{job_id}", response_model=dict)
|
||||
@router.get("/jobs/{job_id}", response_model=Envelope[JobRead])
|
||||
def read_job(
|
||||
project_id: UUID,
|
||||
job_id: UUID,
|
||||
@@ -55,7 +55,7 @@ def read_job(
|
||||
return envelope(job.model_dump())
|
||||
|
||||
|
||||
@router.get("/jobs/{job_id}/status", response_model=dict)
|
||||
@router.get("/jobs/{job_id}/status", response_model=Envelope[JobStatus])
|
||||
def read_job_status(
|
||||
project_id: UUID,
|
||||
job_id: UUID,
|
||||
|
||||
@@ -7,14 +7,15 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.schemas.project import ProjectCreate, ProjectRead, ProjectUpdate
|
||||
from app.schemas import Envelope
|
||||
from app.schemas.project import ProjectCreate, ProjectDeleteResult, ProjectList, ProjectRead, ProjectUpdate
|
||||
from app.services.project_service import ProjectService
|
||||
from app.utils.response import envelope
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["projects"])
|
||||
|
||||
|
||||
@router.get("", response_model=dict)
|
||||
@router.get("", response_model=Envelope[ProjectList])
|
||||
def list_projects(
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
@@ -32,13 +33,13 @@ def list_projects(
|
||||
return envelope({"items": [ProjectRead.model_validate(item).model_dump() for item in projects], "total": total, "limit": limit, "offset": offset})
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED, response_model=dict)
|
||||
@router.post("", status_code=status.HTTP_201_CREATED, response_model=Envelope[ProjectRead])
|
||||
def create_project(payload: ProjectCreate, db: Session = Depends(get_db)):
|
||||
project = ProjectService.create_project(db, payload)
|
||||
return envelope(ProjectRead.model_validate(project).model_dump())
|
||||
|
||||
|
||||
@router.get("/{project_id}", response_model=dict)
|
||||
@router.get("/{project_id}", response_model=Envelope[ProjectRead])
|
||||
def get_project(project_id: UUID, db: Session = Depends(get_db)):
|
||||
project = ProjectService.get_project(db, project_id)
|
||||
if not project:
|
||||
@@ -46,7 +47,7 @@ def get_project(project_id: UUID, db: Session = Depends(get_db)):
|
||||
return envelope(ProjectRead.model_validate(project).model_dump())
|
||||
|
||||
|
||||
@router.patch("/{project_id}", response_model=dict)
|
||||
@router.patch("/{project_id}", response_model=Envelope[ProjectRead])
|
||||
def update_project(project_id: UUID, payload: ProjectUpdate, db: Session = Depends(get_db)):
|
||||
project = ProjectService.update_project(db, project_id, payload)
|
||||
if not project:
|
||||
@@ -54,7 +55,11 @@ def update_project(project_id: UUID, payload: ProjectUpdate, db: Session = Depen
|
||||
return envelope(ProjectRead.model_validate(project).model_dump())
|
||||
|
||||
|
||||
@router.delete("/{project_id}", status_code=status.HTTP_200_OK, response_model=dict)
|
||||
@router.delete(
|
||||
"/{project_id}",
|
||||
status_code=status.HTTP_200_OK,
|
||||
response_model=Envelope[ProjectDeleteResult],
|
||||
)
|
||||
def delete_project(project_id: UUID, db: Session = Depends(get_db)):
|
||||
if not ProjectService.delete_project(db, project_id):
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
@@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
|
||||
from app.db.session import get_db
|
||||
from app.core.errors import AppError
|
||||
from app.models import Dataset, Job
|
||||
from app.schemas import QaProviderComparisonRequest
|
||||
from app.schemas import Envelope, JobRead, QaProviderComparisonRequest
|
||||
from app.services.qa_service import QaService
|
||||
from app.services.job_service import JobService
|
||||
from app.services.quality_service import QualityService
|
||||
@@ -17,7 +17,7 @@ from app.utils.response import envelope
|
||||
router = APIRouter(prefix="/qa", tags=["qa"])
|
||||
|
||||
|
||||
@router.post("/detections-vs-reference")
|
||||
@router.post("/detections-vs-reference", response_model=Envelope[JobRead])
|
||||
def compare_candidate_with_reference(
|
||||
payload: QaProviderComparisonRequest,
|
||||
db: Session = Depends(get_db),
|
||||
|
||||
@@ -6,7 +6,8 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.schemas.detection_review import DetectionReviewUpsert
|
||||
from app.schemas import Envelope, QualityEvidenceResponse
|
||||
from app.schemas.detection_review import DetectionReviewList, DetectionReviewRead, DetectionReviewUpsert
|
||||
from app.schemas.qa import QualityCheckList
|
||||
from app.services.detection_review_service import DetectionReviewService
|
||||
from app.services.quality_evidence_service import QualityEvidenceService
|
||||
@@ -16,7 +17,7 @@ from app.utils.response import envelope
|
||||
router = APIRouter(prefix="/projects/{project_id}", tags=["quality-checks"])
|
||||
|
||||
|
||||
@router.get("/quality-checks", response_model=dict)
|
||||
@router.get("/quality-checks", response_model=Envelope[QualityCheckList])
|
||||
def list_quality_checks(
|
||||
project_id: UUID,
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
@@ -32,7 +33,10 @@ def list_quality_checks(
|
||||
return envelope(QualityCheckList(items=items, total=total, limit=limit, offset=offset).model_dump())
|
||||
|
||||
|
||||
@router.get("/quality-checks/{quality_check_id}/evidence/geojson", response_model=dict)
|
||||
@router.get(
|
||||
"/quality-checks/{quality_check_id}/evidence/geojson",
|
||||
response_model=Envelope[QualityEvidenceResponse],
|
||||
)
|
||||
def get_quality_check_evidence_geojson(
|
||||
project_id: UUID,
|
||||
quality_check_id: UUID,
|
||||
@@ -41,7 +45,10 @@ def get_quality_check_evidence_geojson(
|
||||
return envelope(QualityEvidenceService.evidence_geojson(db, project_id=project_id, quality_check_id=quality_check_id))
|
||||
|
||||
|
||||
@router.get("/quality-checks/{quality_check_id}/reviews", response_model=dict)
|
||||
@router.get(
|
||||
"/quality-checks/{quality_check_id}/reviews",
|
||||
response_model=Envelope[DetectionReviewList],
|
||||
)
|
||||
def list_detection_reviews(
|
||||
project_id: UUID,
|
||||
quality_check_id: UUID,
|
||||
@@ -66,7 +73,10 @@ def list_detection_reviews(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/quality-checks/{quality_check_id}/reviews", response_model=dict)
|
||||
@router.post(
|
||||
"/quality-checks/{quality_check_id}/reviews",
|
||||
response_model=Envelope[DetectionReviewRead],
|
||||
)
|
||||
def upsert_detection_review(
|
||||
project_id: UUID,
|
||||
quality_check_id: UUID,
|
||||
|
||||
@@ -6,7 +6,19 @@ from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.schemas import SegmentationQaRequest, SegmentationRunRequest
|
||||
from app.schemas import (
|
||||
AnalysisQaResponse,
|
||||
Envelope,
|
||||
GeoJsonFeatureCollection,
|
||||
SegmentationListResponse,
|
||||
SegmentationModelsResponse,
|
||||
SegmentationQaRequest,
|
||||
SegmentationRead,
|
||||
SegmentationRunListResponse,
|
||||
SegmentationRunRead,
|
||||
SegmentationRunRequest,
|
||||
SegmentationRunResponse,
|
||||
)
|
||||
from app.services.model_registry_service import ModelRegistryService
|
||||
from app.services.segmentation_service import SegmentationService
|
||||
from app.utils.response import envelope
|
||||
@@ -14,12 +26,12 @@ from app.utils.response import envelope
|
||||
router = APIRouter(prefix="/segmentation", tags=["segmentation"])
|
||||
|
||||
|
||||
@router.get("/models", response_model=dict)
|
||||
@router.get("/models", response_model=Envelope[SegmentationModelsResponse])
|
||||
def list_segmentation_models() -> dict:
|
||||
return envelope({"models": [model.model_dump() for model in ModelRegistryService.list_model_capabilities(task_type="segmentation")]})
|
||||
|
||||
|
||||
@router.post("/run", response_model=dict)
|
||||
@router.post("/run", response_model=Envelope[SegmentationRunResponse])
|
||||
def run_segmentation(payload: SegmentationRunRequest, db: Session = Depends(get_db)) -> dict:
|
||||
result = SegmentationService.run_segmentation(
|
||||
db=db,
|
||||
@@ -34,7 +46,7 @@ def run_segmentation(payload: SegmentationRunRequest, db: Session = Depends(get_
|
||||
return envelope(result.model_dump())
|
||||
|
||||
|
||||
@router.get("/runs", response_model=dict)
|
||||
@router.get("/runs", response_model=Envelope[SegmentationRunListResponse])
|
||||
def list_segmentation_runs(
|
||||
project_id: UUID | None = None,
|
||||
dataset_id: UUID | None = None,
|
||||
@@ -43,12 +55,15 @@ def list_segmentation_runs(
|
||||
return envelope(SegmentationService.list_runs(db, project_id=project_id, dataset_id=dataset_id).model_dump())
|
||||
|
||||
|
||||
@router.get("/runs/{analysis_run_id}", response_model=dict)
|
||||
@router.get("/runs/{analysis_run_id}", response_model=Envelope[SegmentationRunRead])
|
||||
def get_segmentation_run(analysis_run_id: UUID, db: Session = Depends(get_db)) -> dict:
|
||||
return envelope(SegmentationService.get_run(db, analysis_run_id).model_dump())
|
||||
|
||||
|
||||
@router.get("/runs/{analysis_run_id}/segmentations", response_model=dict)
|
||||
@router.get(
|
||||
"/runs/{analysis_run_id}/segmentations",
|
||||
response_model=Envelope[SegmentationListResponse],
|
||||
)
|
||||
def list_segmentation_run_outputs(
|
||||
analysis_run_id: UUID,
|
||||
dataset_id: UUID | None = None,
|
||||
@@ -67,7 +82,10 @@ def list_segmentation_run_outputs(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/datasets/{dataset_id}/segmentations", response_model=dict)
|
||||
@router.get(
|
||||
"/datasets/{dataset_id}/segmentations",
|
||||
response_model=Envelope[SegmentationListResponse],
|
||||
)
|
||||
def list_dataset_segmentations(
|
||||
dataset_id: UUID,
|
||||
analysis_run_id: UUID | None = None,
|
||||
@@ -86,12 +104,15 @@ def list_dataset_segmentations(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/segmentations/{segmentation_id}", response_model=dict)
|
||||
@router.get("/segmentations/{segmentation_id}", response_model=Envelope[SegmentationRead])
|
||||
def get_segmentation(segmentation_id: UUID, db: Session = Depends(get_db)) -> dict:
|
||||
return envelope(SegmentationService.get_segmentation(db, segmentation_id).model_dump())
|
||||
|
||||
|
||||
@router.get("/runs/{analysis_run_id}/geojson", response_model=dict)
|
||||
@router.get(
|
||||
"/runs/{analysis_run_id}/geojson",
|
||||
response_model=Envelope[GeoJsonFeatureCollection],
|
||||
)
|
||||
def get_segmentation_run_geojson(
|
||||
analysis_run_id: UUID,
|
||||
class_name: str | None = None,
|
||||
@@ -108,7 +129,10 @@ def get_segmentation_run_geojson(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/datasets/{dataset_id}/geojson", response_model=dict)
|
||||
@router.get(
|
||||
"/datasets/{dataset_id}/geojson",
|
||||
response_model=Envelope[GeoJsonFeatureCollection],
|
||||
)
|
||||
def get_dataset_segmentation_geojson(
|
||||
dataset_id: UUID,
|
||||
analysis_run_id: UUID | None = None,
|
||||
@@ -127,7 +151,10 @@ def get_dataset_segmentation_geojson(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/runs/{analysis_run_id}/qa/reference", response_model=dict)
|
||||
@router.post(
|
||||
"/runs/{analysis_run_id}/qa/reference",
|
||||
response_model=Envelope[AnalysisQaResponse],
|
||||
)
|
||||
def compare_segmentation_run_with_reference(
|
||||
analysis_run_id: UUID,
|
||||
payload: SegmentationQaRequest,
|
||||
|
||||
@@ -6,7 +6,12 @@ from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.schemas.temporal import TemporalComparisonRequest
|
||||
from app.schemas import Envelope, ItemList
|
||||
from app.schemas.temporal import (
|
||||
TemporalComparisonRequest,
|
||||
TemporalComparisonResponse,
|
||||
TemporalSeriesRead,
|
||||
)
|
||||
from app.services.temporal_analysis_service import TemporalAnalysisService
|
||||
from app.utils.response import envelope
|
||||
|
||||
@@ -14,13 +19,13 @@ from app.utils.response import envelope
|
||||
router = APIRouter(prefix="/projects/{project_id}/temporal", tags=["temporal"])
|
||||
|
||||
|
||||
@router.get("/series", response_model=dict)
|
||||
@router.get("/series", response_model=Envelope[ItemList[TemporalSeriesRead]])
|
||||
def list_temporal_series(project_id: UUID, db: Session = Depends(get_db)):
|
||||
series = TemporalAnalysisService.list_series(db, project_id)
|
||||
return envelope({"items": [item.model_dump() for item in series], "total": len(series)})
|
||||
|
||||
|
||||
@router.post("/compare", response_model=dict)
|
||||
@router.post("/compare", response_model=Envelope[TemporalComparisonResponse])
|
||||
def compare_temporal_snapshots(
|
||||
project_id: UUID,
|
||||
payload: TemporalComparisonRequest,
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .common import ApiErrorEnvelope, ApiErrorItem, Envelope, PaginationEnvelope
|
||||
from .common import (
|
||||
ApiErrorEnvelope,
|
||||
ApiErrorItem,
|
||||
Envelope,
|
||||
GeoJsonFeature,
|
||||
GeoJsonFeatureCollection,
|
||||
ItemList,
|
||||
PaginationEnvelope,
|
||||
)
|
||||
from .coverage import (
|
||||
CoverageBBox,
|
||||
CoverageCatalogResponse,
|
||||
@@ -9,7 +17,7 @@ from .coverage import (
|
||||
CoverageResolveResponse,
|
||||
CoverageSourceContract,
|
||||
)
|
||||
from .project import ProjectCreate, ProjectList, ProjectRead, ProjectUpdate
|
||||
from .project import ProjectCreate, ProjectDeleteResult, ProjectList, ProjectRead, ProjectUpdate
|
||||
from .area import AreaCreate, AreaList, AreaRead, AreaUpdate
|
||||
from .analysis import ChangeDetectionRequest, ChangeDetectionSummary
|
||||
from .dataset import DatasetCreateResponse, DatasetList
|
||||
@@ -43,6 +51,7 @@ from .detection import (
|
||||
DetectionRunResponse,
|
||||
ModelAssetListResponse,
|
||||
ModelAssetRead,
|
||||
YoloPreflightResponse,
|
||||
)
|
||||
from .detection_review import DetectionReviewList, DetectionReviewRead, DetectionReviewSummary, DetectionReviewUpsert
|
||||
from .segmentation import (
|
||||
@@ -115,7 +124,12 @@ from .export import (
|
||||
MetadataExportRequest,
|
||||
ReportExportRequest,
|
||||
)
|
||||
from .qa import QaProviderComparisonRequest, QaProviderComparisonResult
|
||||
from .qa import (
|
||||
AnalysisQaResponse,
|
||||
QaProviderComparisonRequest,
|
||||
QaProviderComparisonResult,
|
||||
QualityEvidenceResponse,
|
||||
)
|
||||
from .operations import (
|
||||
RasterClipRequest,
|
||||
RasterIndexBaseRequest,
|
||||
@@ -150,6 +164,9 @@ from .operations import (
|
||||
|
||||
__all__ = [
|
||||
"Envelope",
|
||||
"ItemList",
|
||||
"GeoJsonFeature",
|
||||
"GeoJsonFeatureCollection",
|
||||
"ApiErrorEnvelope",
|
||||
"ApiErrorItem",
|
||||
"PaginationEnvelope",
|
||||
@@ -163,6 +180,7 @@ __all__ = [
|
||||
"ProjectRead",
|
||||
"ProjectUpdate",
|
||||
"ProjectList",
|
||||
"ProjectDeleteResult",
|
||||
"AreaCreate",
|
||||
"AreaRead",
|
||||
"AreaUpdate",
|
||||
@@ -198,6 +216,7 @@ __all__ = [
|
||||
"DetectionRunResponse",
|
||||
"ModelAssetListResponse",
|
||||
"ModelAssetRead",
|
||||
"YoloPreflightResponse",
|
||||
"DetectionReviewList",
|
||||
"DetectionReviewRead",
|
||||
"DetectionReviewSummary",
|
||||
@@ -295,4 +314,6 @@ __all__ = [
|
||||
"ExportContentResponse",
|
||||
"QaProviderComparisonRequest",
|
||||
"QaProviderComparisonResult",
|
||||
"AnalysisQaResponse",
|
||||
"QualityEvidenceResponse",
|
||||
]
|
||||
|
||||
@@ -25,6 +25,7 @@ class AreaRead(BaseModel):
|
||||
area_m2: float | None
|
||||
created_at: datetime | None = None
|
||||
geometry_type: str | None = None
|
||||
geometry: dict | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
@@ -30,6 +30,12 @@ class AssistantModelRead(BaseModel):
|
||||
capabilities: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AssistantModelList(BaseModel):
|
||||
items: list[AssistantModelRead]
|
||||
total: int
|
||||
default_model: str | None = None
|
||||
|
||||
|
||||
class AssistantStatus(BaseModel):
|
||||
enabled: bool
|
||||
reachable: bool
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Generic, Literal, TypeVar
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class Envelope(BaseModel):
|
||||
data: object
|
||||
DataT = TypeVar("DataT")
|
||||
|
||||
|
||||
class PaginatedEnvelope(BaseModel):
|
||||
items: list
|
||||
class Envelope(BaseModel, Generic[DataT]):
|
||||
data: DataT
|
||||
|
||||
|
||||
class ItemList(BaseModel, Generic[DataT]):
|
||||
items: list[DataT]
|
||||
total: int
|
||||
|
||||
|
||||
class PaginatedEnvelope(ItemList[DataT], Generic[DataT]):
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
@@ -28,4 +36,19 @@ class ApiErrorItem(BaseModel):
|
||||
|
||||
|
||||
class ApiErrorEnvelope(BaseModel):
|
||||
error: ApiErrorItem
|
||||
error: str
|
||||
message: str
|
||||
details: dict | list = Field(default_factory=dict)
|
||||
request_id: str | None = None
|
||||
|
||||
|
||||
class GeoJsonFeature(BaseModel):
|
||||
type: Literal["Feature"]
|
||||
id: str | int | None = None
|
||||
geometry: dict[str, Any] | None
|
||||
properties: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class GeoJsonFeatureCollection(BaseModel):
|
||||
type: Literal["FeatureCollection"]
|
||||
features: list[GeoJsonFeature]
|
||||
|
||||
@@ -129,3 +129,48 @@ class DetectionRead(BaseModel):
|
||||
class DetectionListResponse(BaseModel):
|
||||
items: list[DetectionRead]
|
||||
total: int
|
||||
|
||||
|
||||
class YoloPreflightChecks(BaseModel):
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
enabled: bool
|
||||
dependencies_available: bool | None = None
|
||||
model_path_set: bool | None = None
|
||||
model_file_exists: bool | None = None
|
||||
model_load_requested: bool
|
||||
model_load_ok: bool | None = None
|
||||
manifest_path_set: bool | None = None
|
||||
manifest_valid: bool | None = None
|
||||
tile_paths_exist: bool | None = None
|
||||
tile_limit_ok: bool | None = None
|
||||
|
||||
|
||||
class YoloRuntimeDetails(BaseModel):
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
dependencies_assumed: bool
|
||||
model_directory: str | None = None
|
||||
yolo_config_dir: str | None = None
|
||||
torch_version: str | None = None
|
||||
ultralytics_version: str | None = None
|
||||
cuda_available: bool | None = None
|
||||
|
||||
|
||||
class YoloPreflightResponse(BaseModel):
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
model_id: str
|
||||
model_asset_id: str | None = None
|
||||
model_path: str | None = None
|
||||
tile_manifest_path: str | None = None
|
||||
status: str
|
||||
message: str
|
||||
checks: YoloPreflightChecks
|
||||
tile_count: int
|
||||
max_tiles: int
|
||||
will_download_models: bool
|
||||
will_run_inference: bool
|
||||
runtime: YoloRuntimeDetails
|
||||
error_code: str | None = None
|
||||
details: dict | None = None
|
||||
|
||||
@@ -41,3 +41,7 @@ class ProjectList(BaseModel):
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class ProjectDeleteResult(BaseModel):
|
||||
deleted: bool
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.schemas.common import GeoJsonFeatureCollection
|
||||
|
||||
|
||||
class QaProviderComparisonRequest(BaseModel):
|
||||
candidate_dataset_id: UUID
|
||||
@@ -72,3 +75,40 @@ class QualityCheckList(BaseModel):
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class QualityEvidenceResponse(BaseModel):
|
||||
quality_check_id: UUID
|
||||
project_id: UUID
|
||||
candidate_dataset_id: UUID | None = None
|
||||
reference_dataset_id: UUID
|
||||
analysis_run_id: UUID | None = None
|
||||
feature_count: int
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
geojson: GeoJsonFeatureCollection
|
||||
|
||||
|
||||
class AnalysisQaResponse(BaseModel):
|
||||
status: str
|
||||
quality_check_id: UUID
|
||||
analysis_run_id: UUID
|
||||
reference_dataset_id: UUID
|
||||
candidate_feature_count: int
|
||||
reference_feature_count: int
|
||||
candidate_feature_count_raw: int | None = None
|
||||
reference_feature_count_raw: int | None = None
|
||||
matches: int
|
||||
false_positives: int
|
||||
false_negatives: int
|
||||
precision: float | None = None
|
||||
recall: float | None = None
|
||||
f1_score: float | None = None
|
||||
mean_iou: float | None = None
|
||||
iou_threshold: float
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
coverage: dict[str, Any] | None = None
|
||||
temporal_compatibility: dict[str, Any] | None = None
|
||||
box_to_footprint_diagnostics: dict[str, Any] | None = None
|
||||
match_evidence: list[dict[str, Any]] = Field(default_factory=list)
|
||||
false_positive_evidence: list[dict[str, Any]] = Field(default_factory=list)
|
||||
false_negative_evidence: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
@@ -15,9 +15,19 @@ from app.utils.geometry import area_m2, geometry_bbox_polygon, normalize_to_mult
|
||||
class AreaService:
|
||||
@staticmethod
|
||||
def serialize_area(area: Area) -> dict:
|
||||
payload = AreaRead.model_validate(area).model_dump()
|
||||
payload["geometry"] = mapping(to_shape(area.geometry)) if area.geometry else None
|
||||
return payload
|
||||
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]:
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
BACKEND = ROOT / "backend"
|
||||
|
||||
|
||||
def test_no_untyped_fastapi_response_models_remain() -> None:
|
||||
route_root = BACKEND / "app" / "api" / "routes"
|
||||
route_sources = "\n".join(
|
||||
path.read_text(encoding="utf-8")
|
||||
for path in sorted(route_root.glob("*.py"))
|
||||
)
|
||||
|
||||
assert "response_model=dict" not in route_sources
|
||||
|
||||
|
||||
def test_every_json_success_response_has_a_concrete_canonical_schema() -> None:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(BACKEND))
|
||||
from app.main import create_app
|
||||
|
||||
from scripts.audit_api_contracts import (
|
||||
ALLOWED_NON_ENVELOPE_ENDPOINTS,
|
||||
_validate_response_contracts,
|
||||
)
|
||||
|
||||
openapi = create_app().openapi()
|
||||
assert _validate_response_contracts(openapi) == []
|
||||
|
||||
untyped_successes: set[tuple[str, str]] = set()
|
||||
for path, path_item in openapi["paths"].items():
|
||||
for method, operation in path_item.items():
|
||||
if method.upper() not in {"GET", "POST", "PATCH", "DELETE"}:
|
||||
continue
|
||||
has_json_schema = any(
|
||||
response.get("content", {})
|
||||
.get("application/json", {})
|
||||
.get("schema")
|
||||
for code, response in operation.get("responses", {}).items()
|
||||
if str(code).startswith("2")
|
||||
)
|
||||
if not has_json_schema:
|
||||
untyped_successes.add((method.upper(), path))
|
||||
|
||||
assert untyped_successes == ALLOWED_NON_ENVELOPE_ENDPOINTS - {
|
||||
("GET", "/health"),
|
||||
("GET", "/health/live"),
|
||||
("GET", "/health/ready"),
|
||||
}
|
||||
@@ -125,10 +125,15 @@ def test_quality_check_evidence_geojson_rejects_cross_project_access() -> None:
|
||||
def test_quality_check_evidence_geojson_api_uses_canonical_envelope(monkeypatch) -> None:
|
||||
project_id = uuid4()
|
||||
quality_check_id = uuid4()
|
||||
reference_dataset_id = uuid4()
|
||||
payload = {
|
||||
"quality_check_id": str(quality_check_id),
|
||||
"project_id": str(project_id),
|
||||
"candidate_dataset_id": None,
|
||||
"reference_dataset_id": str(reference_dataset_id),
|
||||
"analysis_run_id": None,
|
||||
"feature_count": 0,
|
||||
"warnings": [],
|
||||
"geojson": {"type": "FeatureCollection", "features": []},
|
||||
}
|
||||
|
||||
|
||||
@@ -495,9 +495,25 @@ def test_dhmv_endpoints_use_canonical_envelopes(monkeypatch) -> None:
|
||||
"analyze",
|
||||
lambda *_args, **_kwargs: {
|
||||
"dataset_id": str(output_dataset_id),
|
||||
"product_key": "dtm_1m",
|
||||
"surface_model": "terrain",
|
||||
"selection_bbox": lambert_bbox_payload().bbox.model_dump(),
|
||||
"sample_count": 100,
|
||||
"summary": {"metric_value": 25.0, "metric_unit": "m TAW", "metrics": []},
|
||||
"slope_sample_count": 81,
|
||||
"coverage_ratio": 1.0,
|
||||
"resolution_m": 5.0,
|
||||
"vertical_reference": "TAW",
|
||||
"summary": {
|
||||
"metric_label": "Gemiddelde terreinhoogte",
|
||||
"metric_value": 25.0,
|
||||
"metric_unit": "m TAW",
|
||||
"aggregation_method": "mean",
|
||||
"primary_metric_key": "terrain_elevation_mean_m",
|
||||
"metrics": [],
|
||||
},
|
||||
"unsupported_metrics": ["water_depth_m", "water_volume_m3"],
|
||||
"limitation_message": "Terrain height is not water depth.",
|
||||
"generated_at": "2026-07-18T00:00:00Z",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
@@ -507,7 +523,25 @@ def test_dhmv_endpoints_use_canonical_envelopes(monkeypatch) -> None:
|
||||
"dataset_id": str(output_dataset_id),
|
||||
"dataset_ids": [str(output_dataset_id)],
|
||||
"partition_count": 1,
|
||||
"product_key": "dtm_1m",
|
||||
"surface_model": "terrain",
|
||||
"selection_bbox": lambert_bbox_payload().bbox.model_dump(),
|
||||
"sample_count": 100,
|
||||
"slope_sample_count": 81,
|
||||
"coverage_ratio": 1.0,
|
||||
"resolution_m": 5.0,
|
||||
"vertical_reference": "TAW",
|
||||
"summary": {
|
||||
"metric_label": "Gemiddelde terreinhoogte",
|
||||
"metric_value": 25.0,
|
||||
"metric_unit": "m TAW",
|
||||
"aggregation_method": "mean",
|
||||
"primary_metric_key": "terrain_elevation_mean_m",
|
||||
"metrics": [],
|
||||
},
|
||||
"unsupported_metrics": ["water_depth_m", "water_volume_m3"],
|
||||
"limitation_message": "Terrain height is not water depth.",
|
||||
"generated_at": "2026-07-18T00:00:00Z",
|
||||
},
|
||||
)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
|
||||
@@ -385,9 +385,27 @@ def test_flood_hazard_api_uses_canonical_envelopes(monkeypatch) -> None:
|
||||
"analyze",
|
||||
lambda *_args, **_kwargs: {
|
||||
"dataset_id": str(output_dataset_id),
|
||||
"product_key": "pluviaal_current_t100",
|
||||
"mechanism": "pluviaal",
|
||||
"climate_context": "huidig klimaat",
|
||||
"probability_class": "middelgrote kans",
|
||||
"return_period_years": 100,
|
||||
"selection_bbox": flood_payload().bbox.model_dump(),
|
||||
"selected_cell_count": 10,
|
||||
"inundated_cell_count": 4,
|
||||
"summary": {"metric_value": 0.01, "metric_unit": "ha", "metrics": []},
|
||||
"inundated_fraction": 0.4,
|
||||
"resolution_m": 5.0,
|
||||
"summary": {
|
||||
"metric_label": "Overstroomde oppervlakte",
|
||||
"metric_value": 0.01,
|
||||
"metric_unit": "ha",
|
||||
"aggregation_method": "positive_depth_area",
|
||||
"primary_metric_key": "inundated_area_ha",
|
||||
"metrics": [],
|
||||
},
|
||||
"unsupported_metrics": ["permanent_water_volume_m3"],
|
||||
"limitation_message": "Scenario depth is not bathymetry.",
|
||||
"generated_at": "2026-07-18T00:00:00Z",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
@@ -397,7 +415,27 @@ def test_flood_hazard_api_uses_canonical_envelopes(monkeypatch) -> None:
|
||||
"dataset_id": str(output_dataset_id),
|
||||
"dataset_ids": [str(output_dataset_id)],
|
||||
"partition_count": 1,
|
||||
"product_key": "pluviaal_current_t100",
|
||||
"mechanism": "pluviaal",
|
||||
"climate_context": "huidig klimaat",
|
||||
"probability_class": "middelgrote kans",
|
||||
"return_period_years": 100,
|
||||
"selection_bbox": flood_payload().bbox.model_dump(),
|
||||
"selected_cell_count": 10,
|
||||
"inundated_cell_count": 4,
|
||||
"inundated_fraction": 0.4,
|
||||
"resolution_m": 5.0,
|
||||
"summary": {
|
||||
"metric_label": "Overstroomde oppervlakte",
|
||||
"metric_value": 0.01,
|
||||
"metric_unit": "ha",
|
||||
"aggregation_method": "positive_depth_area",
|
||||
"primary_metric_key": "inundated_area_ha",
|
||||
"metrics": [],
|
||||
},
|
||||
"unsupported_metrics": ["permanent_water_volume_m3"],
|
||||
"limitation_message": "Scenario depth is not bathymetry.",
|
||||
"generated_at": "2026-07-18T00:00:00Z",
|
||||
},
|
||||
)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
|
||||
@@ -514,7 +514,29 @@ def test_api_uses_canonical_envelopes(monkeypatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
ThematicRasterAnalysisService,
|
||||
"analyze",
|
||||
lambda *_args, **_kwargs: {"dataset_id": str(dataset_id), "theme": "population", "summary": {"metric_value": 10.0}},
|
||||
lambda *_args, **_kwargs: {
|
||||
"dataset_id": str(dataset_id),
|
||||
"product_key": "population_density_2019",
|
||||
"theme": "population",
|
||||
"metric_kind": "population_density",
|
||||
"selection_bbox": payload().bbox.model_dump(),
|
||||
"selected_cell_count": 10,
|
||||
"valid_cell_count": 10,
|
||||
"coverage_ratio": 1.0,
|
||||
"resolution_m": 100.0,
|
||||
"observation_year": 2019,
|
||||
"summary": {
|
||||
"metric_label": "Geraamd aantal inwoners",
|
||||
"metric_value": 10.0,
|
||||
"metric_unit": "inwoners",
|
||||
"aggregation_method": "sum_density_cells",
|
||||
"primary_metric_key": "estimated_inhabitants",
|
||||
"metrics": [],
|
||||
},
|
||||
"unsupported_metrics": ["current_population"],
|
||||
"limitation_message": "2019 density estimate.",
|
||||
"generated_at": "2026-07-18T00:00:00Z",
|
||||
},
|
||||
)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
try:
|
||||
|
||||
@@ -8,12 +8,12 @@ def test_partitioned_raster_routes_are_canonical_and_documented() -> None:
|
||||
routes = (ROOT / "backend/app/api/routes/datasets.py").read_text(encoding="utf-8")
|
||||
contracts = (ROOT / "docs/API_CONTRACTS.md").read_text(encoding="utf-8")
|
||||
|
||||
for path in (
|
||||
"/datasets/raster/terrain/select",
|
||||
"/datasets/raster/flood-hazard/select",
|
||||
):
|
||||
assert f'@router.post("{path}", response_model=dict)' in routes
|
||||
assert path in contracts
|
||||
assert '"/datasets/raster/terrain/select",' in routes
|
||||
assert "response_model=Envelope[TerrainSelectionResponse]" in routes
|
||||
assert '"/datasets/raster/flood-hazard/select",' in routes
|
||||
assert "response_model=Envelope[FloodHazardSelectionResponse]" in routes
|
||||
assert "/datasets/raster/terrain/select" in contracts
|
||||
assert "/datasets/raster/flood-hazard/select" in contracts
|
||||
assert "envelope(TerrainAnalysisService.analyze_partitions" in routes
|
||||
assert "envelope(FloodHazardAnalysisService.analyze_partitions" in routes
|
||||
|
||||
|
||||
@@ -165,7 +165,13 @@ def test_mdk_readiness_api_uses_canonical_envelope(monkeypatch) -> None:
|
||||
lambda: {
|
||||
"source_key": "mdk_bcp_bathymetry",
|
||||
"status": "tls_error",
|
||||
"configured_url": "https://example.invalid/wcs",
|
||||
"tls_verified": False,
|
||||
"capabilities_reachable": False,
|
||||
"acquisition_supported": False,
|
||||
"checked_at": "2026-07-18T00:00:00Z",
|
||||
"message": "TLS validation failed.",
|
||||
"limitation_message": "No insecure fallback is permitted.",
|
||||
},
|
||||
)
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
|
||||
Reference in New Issue
Block a user