Harden RC7 API response contracts
This commit is contained in:
@@ -26,6 +26,14 @@
|
|||||||
reports zero reachable fixed HIGH/CRITICAL vulnerabilities.
|
reports zero reachable fixed HIGH/CRITICAL vulnerabilities.
|
||||||
- Folded fresh-install, upgrade, rollback and runtime proof into RC-5 and
|
- Folded fresh-install, upgrade, rollback and runtime proof into RC-5 and
|
||||||
RC-11 instead of creating a separate RC-12 phase.
|
RC-11 instead of creating a separate RC-12 phase.
|
||||||
|
- Completed RC-7 response hardening across every successful JSON route with
|
||||||
|
concrete Pydantic/OpenAPI schemas and the canonical data envelope.
|
||||||
|
- Added an executable OpenAPI contract audit covering 124 implemented routes
|
||||||
|
and 228 component schemas. The eight explicit non-envelope operations are
|
||||||
|
limited to health probes, persisted raster PNG responses and export
|
||||||
|
download.
|
||||||
|
- Passed the RC-7 release gate with 1,008 backend tests, frontend
|
||||||
|
typecheck/build, one Alembic head and offline migration SQL generation.
|
||||||
- Added a read-only release-evidence manifest command with Git, migration,
|
- Added a read-only release-evidence manifest command with Git, migration,
|
||||||
dependency, configuration checksum and optional live endpoint evidence.
|
dependency, configuration checksum and optional live endpoint evidence.
|
||||||
- Replaced the obsolete pre-build status with the current implemented
|
- Replaced the obsolete pre-build status with the current implemented
|
||||||
|
|||||||
@@ -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.
|
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
|
### Golden QA/QC benchmark
|
||||||
|
|
||||||
Sprint 12 includes a deterministic QA/QC regression benchmark using explicit fixture data:
|
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.core.errors import AppError
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.models import Dataset
|
from app.models import Dataset
|
||||||
|
from app.schemas import Envelope, JobRead
|
||||||
from app.schemas.analysis import ChangeDetectionRequest
|
from app.schemas.analysis import ChangeDetectionRequest
|
||||||
from app.services.change_detection_service import ChangeDetectionService
|
from app.services.change_detection_service import ChangeDetectionService
|
||||||
from app.services.job_service import JobService
|
from app.services.job_service import JobService
|
||||||
@@ -14,7 +15,7 @@ from app.utils.response import envelope
|
|||||||
router = APIRouter(prefix="/analysis", tags=["analysis"])
|
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(
|
def run_change_detection(
|
||||||
payload: ChangeDetectionRequest,
|
payload: ChangeDetectionRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
|||||||
@@ -8,14 +8,15 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.models import Area
|
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.services.area_service import AreaService
|
||||||
from app.utils.response import envelope
|
from app.utils.response import envelope
|
||||||
|
|
||||||
router = APIRouter(prefix="/projects/{project_id}/areas", tags=["areas"])
|
router = APIRouter(prefix="/projects/{project_id}/areas", tags=["areas"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=dict)
|
@router.get("", response_model=Envelope[AreaList])
|
||||||
def list_areas(
|
def list_areas(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
limit: int = Query(default=50, ge=1, le=200),
|
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})
|
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)):
|
def create_area(project_id: UUID, payload: AreaCreate, db: Session = Depends(get_db)):
|
||||||
area = AreaService.create_area(db, project_id, payload)
|
area = AreaService.create_area(db, project_id, payload)
|
||||||
return envelope(AreaService.serialize_area(area))
|
return envelope(AreaService.serialize_area(area))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{area_id}", response_model=dict)
|
@router.get("/{area_id}", response_model=Envelope[AreaRead])
|
||||||
def get_area(
|
def get_area(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
area_id: UUID,
|
area_id: UUID,
|
||||||
@@ -44,7 +45,7 @@ def get_area(
|
|||||||
return envelope(AreaService.serialize_area(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(
|
def update_area(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
area_id: UUID,
|
area_id: UUID,
|
||||||
|
|||||||
@@ -6,7 +6,13 @@ from fastapi import APIRouter, Depends
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.db.session import get_db
|
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.services.geo_assistant_service import GeoAssistantService
|
||||||
from app.utils.response import envelope
|
from app.utils.response import envelope
|
||||||
|
|
||||||
@@ -14,12 +20,12 @@ from app.utils.response import envelope
|
|||||||
router = APIRouter(tags=["assistant"])
|
router = APIRouter(tags=["assistant"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/assistant/status", response_model=dict)
|
@router.get("/assistant/status", response_model=Envelope[AssistantStatus])
|
||||||
def assistant_status() -> dict:
|
def assistant_status() -> dict:
|
||||||
return envelope(GeoAssistantService().status().model_dump())
|
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:
|
def assistant_models() -> dict:
|
||||||
service = GeoAssistantService()
|
service = GeoAssistantService()
|
||||||
models = service.list_models()
|
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(
|
def assistant_query(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
payload: AssistantQueryRequest,
|
payload: AssistantQueryRequest,
|
||||||
|
|||||||
@@ -13,6 +13,24 @@ from app.models import Area, Project
|
|||||||
from app.core.errors import AppError
|
from app.core.errors import AppError
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
|
BathymetryPartitionFinalizationResult,
|
||||||
|
BathymetrySourceProbeRead,
|
||||||
|
BathymetrySourceRead,
|
||||||
|
DatasetList,
|
||||||
|
DhmvProductRead,
|
||||||
|
Envelope,
|
||||||
|
FloodHazardProductRead,
|
||||||
|
FloodHazardSelectionResponse,
|
||||||
|
GeoJsonFeatureCollection,
|
||||||
|
GrbProductRead,
|
||||||
|
GrbRefreshPlan,
|
||||||
|
ItemList,
|
||||||
|
JobRead,
|
||||||
|
OfficialVectorProductRead,
|
||||||
|
OrthophotoProductRead,
|
||||||
|
RasterMetadataResponse,
|
||||||
|
RasterOperationResult,
|
||||||
|
RasterPreviewResponse,
|
||||||
RasterClipRequest,
|
RasterClipRequest,
|
||||||
RasterStatsResponse,
|
RasterStatsResponse,
|
||||||
RasterReprojectRequest,
|
RasterReprojectRequest,
|
||||||
@@ -23,6 +41,7 @@ from app.schemas import (
|
|||||||
OrthophotoAcquireRequest,
|
OrthophotoAcquireRequest,
|
||||||
DhmvAcquireRequest,
|
DhmvAcquireRequest,
|
||||||
TerrainPartitionSelectionRequest,
|
TerrainPartitionSelectionRequest,
|
||||||
|
TerrainSelectionResponse,
|
||||||
TerrainSelectionRequest,
|
TerrainSelectionRequest,
|
||||||
FloodHazardAcquireRequest,
|
FloodHazardAcquireRequest,
|
||||||
FloodHazardPartitionSelectionRequest,
|
FloodHazardPartitionSelectionRequest,
|
||||||
@@ -30,6 +49,8 @@ from app.schemas import (
|
|||||||
BathymetryPartitionFinalizeRequest,
|
BathymetryPartitionFinalizeRequest,
|
||||||
BathymetryProfileAcquireRequest,
|
BathymetryProfileAcquireRequest,
|
||||||
ThematicRasterAcquireRequest,
|
ThematicRasterAcquireRequest,
|
||||||
|
ThematicRasterProductRead,
|
||||||
|
ThematicRasterSelectionResponse,
|
||||||
ThematicRasterSelectionRequest,
|
ThematicRasterSelectionRequest,
|
||||||
GrbAcquireRequest,
|
GrbAcquireRequest,
|
||||||
OfficialVectorAcquireRequest,
|
OfficialVectorAcquireRequest,
|
||||||
@@ -37,12 +58,21 @@ from app.schemas import (
|
|||||||
VectorBufferRequest,
|
VectorBufferRequest,
|
||||||
VectorClipRequest,
|
VectorClipRequest,
|
||||||
VectorIntersectRequest,
|
VectorIntersectRequest,
|
||||||
|
VectorOperationResult,
|
||||||
VectorSelectionBBox, # noqa: F401 - retained as a route-module compatibility export
|
VectorSelectionBBox, # noqa: F401 - retained as a route-module compatibility export
|
||||||
VectorSelectionDeriveRequest,
|
VectorSelectionDeriveRequest,
|
||||||
VectorSelectionRequest,
|
VectorSelectionRequest,
|
||||||
VectorSelectionResponse,
|
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.job_service import JobService
|
||||||
from app.services.raster_operations_service import RasterOperationsService
|
from app.services.raster_operations_service import RasterOperationsService
|
||||||
from app.services.vector_operations_service import VectorOperationsService
|
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(
|
async def upload_dataset(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
@@ -149,7 +183,7 @@ async def upload_dataset(
|
|||||||
return envelope(created.model_dump())
|
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(
|
def acquire_bounded_orthophoto(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
payload: OrthophotoAcquireRequest,
|
payload: OrthophotoAcquireRequest,
|
||||||
@@ -165,7 +199,10 @@ def acquire_bounded_orthophoto(
|
|||||||
return envelope(job)
|
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)):
|
def list_orthophoto_products(project_id: UUID, db: Session = Depends(get_db)):
|
||||||
if not db.get(Project, project_id):
|
if not db.get(Project, project_id):
|
||||||
raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404)
|
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)})
|
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(
|
def acquire_bounded_dhmv(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
payload: DhmvAcquireRequest,
|
payload: DhmvAcquireRequest,
|
||||||
@@ -189,7 +226,10 @@ def acquire_bounded_dhmv(
|
|||||||
return envelope(job)
|
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)):
|
def list_dhmv_products(project_id: UUID, db: Session = Depends(get_db)):
|
||||||
if not db.get(Project, project_id):
|
if not db.get(Project, project_id):
|
||||||
raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404)
|
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)})
|
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(
|
def acquire_bounded_grb(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
payload: GrbAcquireRequest,
|
payload: GrbAcquireRequest,
|
||||||
@@ -213,7 +253,10 @@ def acquire_bounded_grb(
|
|||||||
return envelope(job)
|
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)):
|
def list_grb_products(project_id: UUID, db: Session = Depends(get_db)):
|
||||||
if not db.get(Project, project_id):
|
if not db.get(Project, project_id):
|
||||||
raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404)
|
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)})
|
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(
|
def acquire_bounded_official_vector(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
payload: OfficialVectorAcquireRequest,
|
payload: OfficialVectorAcquireRequest,
|
||||||
@@ -237,7 +280,10 @@ def acquire_bounded_official_vector(
|
|||||||
return envelope(job)
|
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)):
|
def list_official_vector_products(project_id: UUID, db: Session = Depends(get_db)):
|
||||||
if not db.get(Project, project_id):
|
if not db.get(Project, project_id):
|
||||||
raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404)
|
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)})
|
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(
|
def acquire_bounded_flood_hazard(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
payload: FloodHazardAcquireRequest,
|
payload: FloodHazardAcquireRequest,
|
||||||
@@ -261,7 +307,10 @@ def acquire_bounded_flood_hazard(
|
|||||||
return envelope(job)
|
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)):
|
def list_flood_hazard_products(project_id: UUID, db: Session = Depends(get_db)):
|
||||||
if not db.get(Project, project_id):
|
if not db.get(Project, project_id):
|
||||||
raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404)
|
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)})
|
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)):
|
def list_bathymetry_sources(project_id: UUID, db: Session = Depends(get_db)):
|
||||||
if not db.get(Project, project_id):
|
if not db.get(Project, project_id):
|
||||||
raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404)
|
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)})
|
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)):
|
def probe_mdk_bathymetry_readiness(project_id: UUID, db: Session = Depends(get_db)):
|
||||||
if not db.get(Project, project_id):
|
if not db.get(Project, project_id):
|
||||||
raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404)
|
raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404)
|
||||||
return envelope(MdkBathymetryProbeService.probe())
|
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(
|
def acquire_bounded_bathymetry_profiles(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
payload: BathymetryProfileAcquireRequest,
|
payload: BathymetryProfileAcquireRequest,
|
||||||
@@ -300,7 +358,10 @@ def acquire_bounded_bathymetry_profiles(
|
|||||||
return envelope(job)
|
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(
|
def finalize_bathymetry_profile_partitions(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
payload: BathymetryPartitionFinalizeRequest,
|
payload: BathymetryPartitionFinalizeRequest,
|
||||||
@@ -309,7 +370,10 @@ def finalize_bathymetry_profile_partitions(
|
|||||||
return envelope(BathymetryProfileAcquisitionService.finalize_partitions(db, project_id, payload))
|
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(
|
def select_bathymetry_profile_partitions(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
payload: VectorSelectionRequest,
|
payload: VectorSelectionRequest,
|
||||||
@@ -344,7 +408,7 @@ def select_bathymetry_profile_partitions(
|
|||||||
return envelope(VectorSelectionResponse(**result).model_dump(exclude_none=True))
|
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(
|
def acquire_bounded_thematic_raster(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
payload: ThematicRasterAcquireRequest,
|
payload: ThematicRasterAcquireRequest,
|
||||||
@@ -360,7 +424,10 @@ def acquire_bounded_thematic_raster(
|
|||||||
return envelope(job)
|
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)):
|
def list_thematic_raster_products(project_id: UUID, db: Session = Depends(get_db)):
|
||||||
if not db.get(Project, project_id):
|
if not db.get(Project, project_id):
|
||||||
raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404)
|
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)})
|
return envelope({"items": items, "total": len(items)})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/datasets", response_model=dict)
|
@router.get("/datasets", response_model=Envelope[DatasetList])
|
||||||
def list_datasets(
|
def list_datasets(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
limit: int = Query(default=50, ge=1, le=200),
|
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})
|
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(
|
def audit_dataset_source_freshness(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -388,7 +458,10 @@ def audit_dataset_source_freshness(
|
|||||||
return envelope(report.model_dump())
|
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(
|
def probe_dataset_source_catalogs(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
refresh: bool = Query(default=False),
|
refresh: bool = Query(default=False),
|
||||||
@@ -398,7 +471,10 @@ def probe_dataset_source_catalogs(
|
|||||||
return envelope(report.model_dump())
|
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(
|
def plan_grb_dataset_refresh(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
scope: str = Query(default=GrbRefreshPlanService.SCOPE),
|
scope: str = Query(default=GrbRefreshPlanService.SCOPE),
|
||||||
@@ -414,7 +490,7 @@ def plan_grb_dataset_refresh(
|
|||||||
return envelope(report.model_dump())
|
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(
|
def get_dataset(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
dataset_id: UUID,
|
dataset_id: UUID,
|
||||||
@@ -426,7 +502,10 @@ def get_dataset(
|
|||||||
return envelope(DatasetCreateResponse.model_validate(dataset).model_dump())
|
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(
|
def update_dataset_temporal_metadata(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
dataset_id: UUID,
|
dataset_id: UUID,
|
||||||
@@ -440,7 +519,10 @@ def update_dataset_temporal_metadata(
|
|||||||
return envelope(updated.model_dump())
|
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(
|
def list_dataset_versions(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
dataset_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)})
|
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(
|
def refresh_dataset_metadata(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
dataset_id: UUID,
|
dataset_id: UUID,
|
||||||
@@ -466,7 +551,10 @@ def refresh_dataset_metadata(
|
|||||||
return envelope(refreshed.model_dump())
|
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(
|
def inspect_vector_dataset(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
dataset_id: UUID,
|
dataset_id: UUID,
|
||||||
@@ -478,7 +566,10 @@ def inspect_vector_dataset(
|
|||||||
return envelope(VectorOperationsService.inspect(db, dataset_id).model_dump())
|
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(
|
def vector_bbox(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
dataset_id: UUID,
|
dataset_id: UUID,
|
||||||
@@ -491,7 +582,10 @@ def vector_bbox(
|
|||||||
return envelope(VectorBBoxResponse(**payload).model_dump())
|
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(
|
def vector_stats(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
dataset_id: UUID,
|
dataset_id: UUID,
|
||||||
@@ -503,7 +597,10 @@ def vector_stats(
|
|||||||
return envelope(VectorOperationsService.stats(db, dataset_id))
|
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(
|
def select_vector_features(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
dataset_id: UUID,
|
dataset_id: UUID,
|
||||||
@@ -562,7 +659,11 @@ def select_vector_features(
|
|||||||
return envelope(VectorSelectionResponse(**result).model_dump(exclude_none=True))
|
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(
|
def derive_vector_selection_dataset(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
dataset_id: UUID,
|
dataset_id: UUID,
|
||||||
@@ -597,7 +698,11 @@ def derive_vector_selection_dataset(
|
|||||||
return envelope(derived.model_dump())
|
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(
|
def clip_vector_dataset(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
dataset_id: UUID,
|
dataset_id: UUID,
|
||||||
@@ -623,7 +728,11 @@ def clip_vector_dataset(
|
|||||||
return envelope(job)
|
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(
|
def buffer_vector_dataset(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
dataset_id: UUID,
|
dataset_id: UUID,
|
||||||
@@ -650,7 +759,11 @@ def buffer_vector_dataset(
|
|||||||
return envelope(job)
|
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(
|
def intersect_vector_dataset(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
dataset_id: UUID,
|
dataset_id: UUID,
|
||||||
@@ -676,7 +789,10 @@ def intersect_vector_dataset(
|
|||||||
return envelope(job)
|
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(
|
def vector_dataset_summary(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
dataset_id: UUID,
|
dataset_id: UUID,
|
||||||
@@ -688,7 +804,10 @@ def vector_dataset_summary(
|
|||||||
return envelope(DatasetService.vector_summary(db, dataset_id))
|
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(
|
def raster_dataset_inspect(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
dataset_id: UUID,
|
dataset_id: UUID,
|
||||||
@@ -701,7 +820,10 @@ def raster_dataset_inspect(
|
|||||||
return envelope(payload)
|
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(
|
def raster_preview_readiness(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
dataset_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(
|
def raster_terrain_selection(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
dataset_id: UUID,
|
dataset_id: UUID,
|
||||||
@@ -737,7 +862,10 @@ def raster_terrain_selection(
|
|||||||
return envelope(TerrainAnalysisService.analyze(db, project_id, dataset_id, payload))
|
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(
|
def partitioned_raster_terrain_selection(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
payload: TerrainPartitionSelectionRequest,
|
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(
|
def raster_flood_hazard_selection(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
dataset_id: UUID,
|
dataset_id: UUID,
|
||||||
@@ -770,7 +901,10 @@ def raster_flood_hazard_selection(
|
|||||||
return envelope(FloodHazardAnalysisService.analyze(db, project_id, dataset_id, payload))
|
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(
|
def partitioned_raster_flood_hazard_selection(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
payload: FloodHazardPartitionSelectionRequest,
|
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(
|
def raster_thematic_selection(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
dataset_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(
|
def raster_stats(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
dataset_id: UUID,
|
dataset_id: UUID,
|
||||||
@@ -830,7 +970,11 @@ def raster_stats(
|
|||||||
return envelope(RasterStatsResponse(**payload).model_dump())
|
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(
|
def raster_reproject_dataset(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
dataset_id: UUID,
|
dataset_id: UUID,
|
||||||
@@ -857,7 +1001,11 @@ def raster_reproject_dataset(
|
|||||||
return envelope(job)
|
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(
|
def raster_clip_dataset(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
dataset_id: UUID,
|
dataset_id: UUID,
|
||||||
@@ -878,7 +1026,11 @@ def raster_clip_dataset(
|
|||||||
return envelope(job)
|
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(
|
def raster_tile_dataset(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
dataset_id: UUID,
|
dataset_id: UUID,
|
||||||
@@ -905,7 +1057,11 @@ def raster_tile_dataset(
|
|||||||
return envelope(job)
|
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(
|
def raster_ndvi_dataset(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
dataset_id: UUID,
|
dataset_id: UUID,
|
||||||
@@ -932,7 +1088,11 @@ def raster_ndvi_dataset(
|
|||||||
return envelope(job)
|
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(
|
def raster_ndwi_dataset(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
dataset_id: UUID,
|
dataset_id: UUID,
|
||||||
@@ -959,7 +1119,11 @@ def raster_ndwi_dataset(
|
|||||||
return envelope(job)
|
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(
|
def raster_ndbi_dataset(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
dataset_id: UUID,
|
dataset_id: UUID,
|
||||||
@@ -986,7 +1150,10 @@ def raster_ndbi_dataset(
|
|||||||
return envelope(job)
|
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(
|
def raster_dataset_metadata(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
dataset_id: UUID,
|
dataset_id: UUID,
|
||||||
@@ -998,7 +1165,10 @@ def raster_dataset_metadata(
|
|||||||
return envelope(RasterOperationsService.metadata(db, dataset_id))
|
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(
|
def dataset_content(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
dataset_id: UUID,
|
dataset_id: UUID,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends, status
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
|
from app.schemas import Envelope
|
||||||
from app.schemas.demo import DemoWorkflowResponse
|
from app.schemas.demo import DemoWorkflowResponse
|
||||||
from app.services.demo_workflow_service import DemoWorkflowService
|
from app.services.demo_workflow_service import DemoWorkflowService
|
||||||
from app.utils.response import envelope
|
from app.utils.response import envelope
|
||||||
@@ -11,7 +12,11 @@ from app.utils.response import envelope
|
|||||||
router = APIRouter(prefix="/demo", tags=["demo"])
|
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:
|
def seed_demo_workflow(db: Session = Depends(get_db)) -> dict:
|
||||||
result: DemoWorkflowResponse = DemoWorkflowService.seed(db)
|
result: DemoWorkflowResponse = DemoWorkflowService.seed(db)
|
||||||
return envelope(result.model_dump())
|
return envelope(result.model_dump())
|
||||||
|
|||||||
@@ -6,7 +6,21 @@ from fastapi import APIRouter, Depends
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.db.session import get_db
|
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.detection_service import DetectionService
|
||||||
from app.services.model_asset_catalog_service import ModelAssetCatalogService
|
from app.services.model_asset_catalog_service import ModelAssetCatalogService
|
||||||
from app.services.model_registry_service import ModelRegistryService
|
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 = APIRouter(prefix="/detection", tags=["detection"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/models", response_model=dict)
|
@router.get("/models", response_model=Envelope[DetectionModelsResponse])
|
||||||
def list_detection_models() -> dict:
|
def list_detection_models() -> dict:
|
||||||
return envelope({"models": [model.model_dump() for model in ModelRegistryService.list_model_capabilities()]})
|
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:
|
def list_detection_model_assets() -> dict:
|
||||||
return envelope(ModelAssetCatalogService.list_assets().model_dump())
|
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(
|
def get_yolo_preflight(
|
||||||
tile_manifest_path: str | None = None,
|
tile_manifest_path: str | None = None,
|
||||||
check_model_load: bool = False,
|
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:
|
def run_detection(payload: DetectionRunRequest, db: Session = Depends(get_db)) -> dict:
|
||||||
result = DetectionService.run_detection(
|
result = DetectionService.run_detection(
|
||||||
db=db,
|
db=db,
|
||||||
@@ -57,7 +71,7 @@ def run_detection(payload: DetectionRunRequest, db: Session = Depends(get_db)) -
|
|||||||
return envelope(result.model_dump())
|
return envelope(result.model_dump())
|
||||||
|
|
||||||
|
|
||||||
@router.get("/runs", response_model=dict)
|
@router.get("/runs", response_model=Envelope[DetectionRunListResponse])
|
||||||
def list_detection_runs(
|
def list_detection_runs(
|
||||||
project_id: UUID | None = None,
|
project_id: UUID | None = None,
|
||||||
dataset_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())
|
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:
|
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())
|
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(
|
def list_detection_run_detections(
|
||||||
analysis_run_id: UUID,
|
analysis_run_id: UUID,
|
||||||
dataset_id: UUID | None = None,
|
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(
|
def list_dataset_detections(
|
||||||
dataset_id: UUID,
|
dataset_id: UUID,
|
||||||
analysis_run_id: UUID | None = None,
|
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:
|
def get_detection(detection_id: UUID, db: Session = Depends(get_db)) -> dict:
|
||||||
return envelope(DetectionService.get_detection(db, detection_id).model_dump())
|
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(
|
def get_detection_run_geojson(
|
||||||
analysis_run_id: UUID,
|
analysis_run_id: UUID,
|
||||||
class_name: str | None = None,
|
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(
|
def get_dataset_detection_geojson(
|
||||||
dataset_id: UUID,
|
dataset_id: UUID,
|
||||||
analysis_run_id: UUID | None = None,
|
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(
|
def compare_detection_run_with_reference(
|
||||||
analysis_run_id: UUID,
|
analysis_run_id: UUID,
|
||||||
payload: DetectionQaRequest,
|
payload: DetectionQaRequest,
|
||||||
|
|||||||
@@ -7,14 +7,24 @@ from fastapi.responses import FileResponse
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.db.session import get_db
|
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.services.export_service import ExportService
|
||||||
from app.utils.response import envelope
|
from app.utils.response import envelope
|
||||||
|
|
||||||
router = APIRouter(prefix="/exports", tags=["exports"])
|
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)):
|
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:
|
if payload.export_kind == "vector_selection" and payload.dataset_id is not None and payload.bbox is not None:
|
||||||
return envelope(
|
return envelope(
|
||||||
@@ -40,22 +50,25 @@ def export_geojson(payload: GeoJsonExportRequest, db: Session = Depends(get_db))
|
|||||||
return envelope({})
|
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)):
|
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"))
|
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)):
|
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"))
|
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)):
|
def export_map_result(payload: MapResultExportRequest, db: Session = Depends(get_db)):
|
||||||
return envelope(ExportService.export_map_result(db, payload).model_dump(mode="json"))
|
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(
|
def list_project_exports(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
limit: int = Query(default=50, ge=1, le=100),
|
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"))
|
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)):
|
def get_export(export_id: UUID, db: Session = Depends(get_db)):
|
||||||
return envelope(ExportService.get_export(db, export_id).model_dump(mode="json"))
|
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)
|
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)):
|
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"))
|
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.db.session import get_db
|
||||||
from app.models import Area, Project
|
from app.models import Area, Project
|
||||||
from app.providers.registry import fetch_provider_data, get_provider, import_provider_dataset, list_provider_capabilities
|
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.services.coverage_registry_service import CoverageRegistryService
|
||||||
from app.utils.response import envelope
|
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()
|
return get_provider(provider_name).capability.to_dict()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/providers")
|
@router.get("/providers", response_model=Envelope[ProviderCapabilitiesResponse])
|
||||||
def list_external_providers() -> dict:
|
def list_external_providers() -> dict:
|
||||||
return envelope({
|
return envelope({
|
||||||
"providers": [provider.to_dict() for provider in list_provider_capabilities()],
|
"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:
|
def get_coverage_catalog() -> dict:
|
||||||
return envelope(CoverageRegistryService.catalog().model_dump())
|
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:
|
def resolve_project_coverage(payload: CoverageResolveRequest, db: Session = Depends(get_db)) -> dict:
|
||||||
result = CoverageRegistryService.resolve(
|
result = CoverageRegistryService.resolve(
|
||||||
db,
|
db,
|
||||||
@@ -63,19 +76,28 @@ def resolve_project_coverage(payload: CoverageResolveRequest, db: Session = Depe
|
|||||||
return envelope(result.model_dump())
|
return envelope(result.model_dump())
|
||||||
|
|
||||||
|
|
||||||
@router.get("/providers/capabilities")
|
@router.get(
|
||||||
|
"/providers/capabilities",
|
||||||
|
response_model=Envelope[ProviderCapabilitiesResponse],
|
||||||
|
)
|
||||||
def get_external_provider_capabilities() -> dict:
|
def get_external_provider_capabilities() -> dict:
|
||||||
return envelope({
|
return envelope({
|
||||||
"providers": [provider.to_dict() for provider in list_provider_capabilities()],
|
"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:
|
def get_external_provider(provider_name: str) -> dict:
|
||||||
return envelope(_provider_payload(provider_name))
|
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:
|
def get_external_provider_layers(provider_name: str) -> dict:
|
||||||
provider = get_provider(provider_name)
|
provider = get_provider(provider_name)
|
||||||
return envelope({
|
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:
|
def get_external_provider_status(provider_name: str) -> dict:
|
||||||
provider = get_provider(provider_name)
|
provider = get_provider(provider_name)
|
||||||
return envelope({
|
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:
|
def import_external_provider_dataset(provider_name: str, payload: ProviderImportRequest) -> dict:
|
||||||
result = import_provider_dataset(
|
result = import_provider_dataset(
|
||||||
provider_name=provider_name,
|
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:
|
def fetch_osm(payload: ExternalFetchRequest, db: Session = Depends(get_db)) -> dict:
|
||||||
_assert_project_exists(db, payload.project_id)
|
_assert_project_exists(db, payload.project_id)
|
||||||
_validate_area_in_project(db, payload.project_id, payload.area_id)
|
_validate_area_in_project(db, payload.project_id, payload.area_id)
|
||||||
return envelope(_run_fetch(payload, "osm").model_dump())
|
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:
|
def fetch_grb(payload: ExternalFetchRequest, db: Session = Depends(get_db)) -> dict:
|
||||||
_assert_project_exists(db, payload.project_id)
|
_assert_project_exists(db, payload.project_id)
|
||||||
_validate_area_in_project(db, payload.project_id, payload.area_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 sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.db.session import get_db
|
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.services.job_service import JobService
|
||||||
from app.utils.response import envelope
|
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 = 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(
|
def create_job(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
payload: JobCreate,
|
payload: JobCreate,
|
||||||
@@ -25,7 +25,7 @@ def create_job(
|
|||||||
return envelope(JobService.create_job(db, payload).model_dump())
|
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(
|
def list_jobs(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
dataset_id: UUID | None = Query(default=None),
|
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())
|
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(
|
def read_job(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
job_id: UUID,
|
job_id: UUID,
|
||||||
@@ -55,7 +55,7 @@ def read_job(
|
|||||||
return envelope(job.model_dump())
|
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(
|
def read_job_status(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
job_id: UUID,
|
job_id: UUID,
|
||||||
|
|||||||
@@ -7,14 +7,15 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.db.session import get_db
|
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.services.project_service import ProjectService
|
||||||
from app.utils.response import envelope
|
from app.utils.response import envelope
|
||||||
|
|
||||||
router = APIRouter(prefix="/projects", tags=["projects"])
|
router = APIRouter(prefix="/projects", tags=["projects"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=dict)
|
@router.get("", response_model=Envelope[ProjectList])
|
||||||
def list_projects(
|
def list_projects(
|
||||||
limit: int = Query(default=50, ge=1, le=200),
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
offset: int = Query(default=0, ge=0),
|
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})
|
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)):
|
def create_project(payload: ProjectCreate, db: Session = Depends(get_db)):
|
||||||
project = ProjectService.create_project(db, payload)
|
project = ProjectService.create_project(db, payload)
|
||||||
return envelope(ProjectRead.model_validate(project).model_dump())
|
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)):
|
def get_project(project_id: UUID, db: Session = Depends(get_db)):
|
||||||
project = ProjectService.get_project(db, project_id)
|
project = ProjectService.get_project(db, project_id)
|
||||||
if not project:
|
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())
|
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)):
|
def update_project(project_id: UUID, payload: ProjectUpdate, db: Session = Depends(get_db)):
|
||||||
project = ProjectService.update_project(db, project_id, payload)
|
project = ProjectService.update_project(db, project_id, payload)
|
||||||
if not project:
|
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())
|
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)):
|
def delete_project(project_id: UUID, db: Session = Depends(get_db)):
|
||||||
if not ProjectService.delete_project(db, project_id):
|
if not ProjectService.delete_project(db, project_id):
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
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.db.session import get_db
|
||||||
from app.core.errors import AppError
|
from app.core.errors import AppError
|
||||||
from app.models import Dataset, Job
|
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.qa_service import QaService
|
||||||
from app.services.job_service import JobService
|
from app.services.job_service import JobService
|
||||||
from app.services.quality_service import QualityService
|
from app.services.quality_service import QualityService
|
||||||
@@ -17,7 +17,7 @@ from app.utils.response import envelope
|
|||||||
router = APIRouter(prefix="/qa", tags=["qa"])
|
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(
|
def compare_candidate_with_reference(
|
||||||
payload: QaProviderComparisonRequest,
|
payload: QaProviderComparisonRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.db.session import get_db
|
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.schemas.qa import QualityCheckList
|
||||||
from app.services.detection_review_service import DetectionReviewService
|
from app.services.detection_review_service import DetectionReviewService
|
||||||
from app.services.quality_evidence_service import QualityEvidenceService
|
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 = 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(
|
def list_quality_checks(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
limit: int = Query(default=50, ge=1, le=200),
|
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())
|
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(
|
def get_quality_check_evidence_geojson(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
quality_check_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))
|
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(
|
def list_detection_reviews(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
quality_check_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(
|
def upsert_detection_review(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
quality_check_id: UUID,
|
quality_check_id: UUID,
|
||||||
|
|||||||
@@ -6,7 +6,19 @@ from fastapi import APIRouter, Depends
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.db.session import get_db
|
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.model_registry_service import ModelRegistryService
|
||||||
from app.services.segmentation_service import SegmentationService
|
from app.services.segmentation_service import SegmentationService
|
||||||
from app.utils.response import envelope
|
from app.utils.response import envelope
|
||||||
@@ -14,12 +26,12 @@ from app.utils.response import envelope
|
|||||||
router = APIRouter(prefix="/segmentation", tags=["segmentation"])
|
router = APIRouter(prefix="/segmentation", tags=["segmentation"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/models", response_model=dict)
|
@router.get("/models", response_model=Envelope[SegmentationModelsResponse])
|
||||||
def list_segmentation_models() -> dict:
|
def list_segmentation_models() -> dict:
|
||||||
return envelope({"models": [model.model_dump() for model in ModelRegistryService.list_model_capabilities(task_type="segmentation")]})
|
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:
|
def run_segmentation(payload: SegmentationRunRequest, db: Session = Depends(get_db)) -> dict:
|
||||||
result = SegmentationService.run_segmentation(
|
result = SegmentationService.run_segmentation(
|
||||||
db=db,
|
db=db,
|
||||||
@@ -34,7 +46,7 @@ def run_segmentation(payload: SegmentationRunRequest, db: Session = Depends(get_
|
|||||||
return envelope(result.model_dump())
|
return envelope(result.model_dump())
|
||||||
|
|
||||||
|
|
||||||
@router.get("/runs", response_model=dict)
|
@router.get("/runs", response_model=Envelope[SegmentationRunListResponse])
|
||||||
def list_segmentation_runs(
|
def list_segmentation_runs(
|
||||||
project_id: UUID | None = None,
|
project_id: UUID | None = None,
|
||||||
dataset_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())
|
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:
|
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())
|
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(
|
def list_segmentation_run_outputs(
|
||||||
analysis_run_id: UUID,
|
analysis_run_id: UUID,
|
||||||
dataset_id: UUID | None = None,
|
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(
|
def list_dataset_segmentations(
|
||||||
dataset_id: UUID,
|
dataset_id: UUID,
|
||||||
analysis_run_id: UUID | None = None,
|
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:
|
def get_segmentation(segmentation_id: UUID, db: Session = Depends(get_db)) -> dict:
|
||||||
return envelope(SegmentationService.get_segmentation(db, segmentation_id).model_dump())
|
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(
|
def get_segmentation_run_geojson(
|
||||||
analysis_run_id: UUID,
|
analysis_run_id: UUID,
|
||||||
class_name: str | None = None,
|
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(
|
def get_dataset_segmentation_geojson(
|
||||||
dataset_id: UUID,
|
dataset_id: UUID,
|
||||||
analysis_run_id: UUID | None = None,
|
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(
|
def compare_segmentation_run_with_reference(
|
||||||
analysis_run_id: UUID,
|
analysis_run_id: UUID,
|
||||||
payload: SegmentationQaRequest,
|
payload: SegmentationQaRequest,
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ from fastapi import APIRouter, Depends
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.db.session import get_db
|
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.services.temporal_analysis_service import TemporalAnalysisService
|
||||||
from app.utils.response import envelope
|
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 = 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)):
|
def list_temporal_series(project_id: UUID, db: Session = Depends(get_db)):
|
||||||
series = TemporalAnalysisService.list_series(db, project_id)
|
series = TemporalAnalysisService.list_series(db, project_id)
|
||||||
return envelope({"items": [item.model_dump() for item in series], "total": len(series)})
|
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(
|
def compare_temporal_snapshots(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
payload: TemporalComparisonRequest,
|
payload: TemporalComparisonRequest,
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .common import ApiErrorEnvelope, ApiErrorItem, Envelope, PaginationEnvelope
|
from .common import (
|
||||||
|
ApiErrorEnvelope,
|
||||||
|
ApiErrorItem,
|
||||||
|
Envelope,
|
||||||
|
GeoJsonFeature,
|
||||||
|
GeoJsonFeatureCollection,
|
||||||
|
ItemList,
|
||||||
|
PaginationEnvelope,
|
||||||
|
)
|
||||||
from .coverage import (
|
from .coverage import (
|
||||||
CoverageBBox,
|
CoverageBBox,
|
||||||
CoverageCatalogResponse,
|
CoverageCatalogResponse,
|
||||||
@@ -9,7 +17,7 @@ from .coverage import (
|
|||||||
CoverageResolveResponse,
|
CoverageResolveResponse,
|
||||||
CoverageSourceContract,
|
CoverageSourceContract,
|
||||||
)
|
)
|
||||||
from .project import ProjectCreate, ProjectList, ProjectRead, ProjectUpdate
|
from .project import ProjectCreate, ProjectDeleteResult, ProjectList, ProjectRead, ProjectUpdate
|
||||||
from .area import AreaCreate, AreaList, AreaRead, AreaUpdate
|
from .area import AreaCreate, AreaList, AreaRead, AreaUpdate
|
||||||
from .analysis import ChangeDetectionRequest, ChangeDetectionSummary
|
from .analysis import ChangeDetectionRequest, ChangeDetectionSummary
|
||||||
from .dataset import DatasetCreateResponse, DatasetList
|
from .dataset import DatasetCreateResponse, DatasetList
|
||||||
@@ -43,6 +51,7 @@ from .detection import (
|
|||||||
DetectionRunResponse,
|
DetectionRunResponse,
|
||||||
ModelAssetListResponse,
|
ModelAssetListResponse,
|
||||||
ModelAssetRead,
|
ModelAssetRead,
|
||||||
|
YoloPreflightResponse,
|
||||||
)
|
)
|
||||||
from .detection_review import DetectionReviewList, DetectionReviewRead, DetectionReviewSummary, DetectionReviewUpsert
|
from .detection_review import DetectionReviewList, DetectionReviewRead, DetectionReviewSummary, DetectionReviewUpsert
|
||||||
from .segmentation import (
|
from .segmentation import (
|
||||||
@@ -115,7 +124,12 @@ from .export import (
|
|||||||
MetadataExportRequest,
|
MetadataExportRequest,
|
||||||
ReportExportRequest,
|
ReportExportRequest,
|
||||||
)
|
)
|
||||||
from .qa import QaProviderComparisonRequest, QaProviderComparisonResult
|
from .qa import (
|
||||||
|
AnalysisQaResponse,
|
||||||
|
QaProviderComparisonRequest,
|
||||||
|
QaProviderComparisonResult,
|
||||||
|
QualityEvidenceResponse,
|
||||||
|
)
|
||||||
from .operations import (
|
from .operations import (
|
||||||
RasterClipRequest,
|
RasterClipRequest,
|
||||||
RasterIndexBaseRequest,
|
RasterIndexBaseRequest,
|
||||||
@@ -150,6 +164,9 @@ from .operations import (
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Envelope",
|
"Envelope",
|
||||||
|
"ItemList",
|
||||||
|
"GeoJsonFeature",
|
||||||
|
"GeoJsonFeatureCollection",
|
||||||
"ApiErrorEnvelope",
|
"ApiErrorEnvelope",
|
||||||
"ApiErrorItem",
|
"ApiErrorItem",
|
||||||
"PaginationEnvelope",
|
"PaginationEnvelope",
|
||||||
@@ -163,6 +180,7 @@ __all__ = [
|
|||||||
"ProjectRead",
|
"ProjectRead",
|
||||||
"ProjectUpdate",
|
"ProjectUpdate",
|
||||||
"ProjectList",
|
"ProjectList",
|
||||||
|
"ProjectDeleteResult",
|
||||||
"AreaCreate",
|
"AreaCreate",
|
||||||
"AreaRead",
|
"AreaRead",
|
||||||
"AreaUpdate",
|
"AreaUpdate",
|
||||||
@@ -198,6 +216,7 @@ __all__ = [
|
|||||||
"DetectionRunResponse",
|
"DetectionRunResponse",
|
||||||
"ModelAssetListResponse",
|
"ModelAssetListResponse",
|
||||||
"ModelAssetRead",
|
"ModelAssetRead",
|
||||||
|
"YoloPreflightResponse",
|
||||||
"DetectionReviewList",
|
"DetectionReviewList",
|
||||||
"DetectionReviewRead",
|
"DetectionReviewRead",
|
||||||
"DetectionReviewSummary",
|
"DetectionReviewSummary",
|
||||||
@@ -295,4 +314,6 @@ __all__ = [
|
|||||||
"ExportContentResponse",
|
"ExportContentResponse",
|
||||||
"QaProviderComparisonRequest",
|
"QaProviderComparisonRequest",
|
||||||
"QaProviderComparisonResult",
|
"QaProviderComparisonResult",
|
||||||
|
"AnalysisQaResponse",
|
||||||
|
"QualityEvidenceResponse",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class AreaRead(BaseModel):
|
|||||||
area_m2: float | None
|
area_m2: float | None
|
||||||
created_at: datetime | None = None
|
created_at: datetime | None = None
|
||||||
geometry_type: str | None = None
|
geometry_type: str | None = None
|
||||||
|
geometry: dict | None = None
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ class AssistantModelRead(BaseModel):
|
|||||||
capabilities: list[str] = Field(default_factory=list)
|
capabilities: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class AssistantModelList(BaseModel):
|
||||||
|
items: list[AssistantModelRead]
|
||||||
|
total: int
|
||||||
|
default_model: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class AssistantStatus(BaseModel):
|
class AssistantStatus(BaseModel):
|
||||||
enabled: bool
|
enabled: bool
|
||||||
reachable: bool
|
reachable: bool
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Generic, Literal, TypeVar
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
class Envelope(BaseModel):
|
DataT = TypeVar("DataT")
|
||||||
data: object
|
|
||||||
|
|
||||||
|
|
||||||
class PaginatedEnvelope(BaseModel):
|
class Envelope(BaseModel, Generic[DataT]):
|
||||||
items: list
|
data: DataT
|
||||||
|
|
||||||
|
|
||||||
|
class ItemList(BaseModel, Generic[DataT]):
|
||||||
|
items: list[DataT]
|
||||||
total: int
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class PaginatedEnvelope(ItemList[DataT], Generic[DataT]):
|
||||||
limit: int
|
limit: int
|
||||||
offset: int
|
offset: int
|
||||||
|
|
||||||
@@ -28,4 +36,19 @@ class ApiErrorItem(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class ApiErrorEnvelope(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):
|
class DetectionListResponse(BaseModel):
|
||||||
items: list[DetectionRead]
|
items: list[DetectionRead]
|
||||||
total: int
|
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
|
total: int
|
||||||
limit: int
|
limit: int
|
||||||
offset: int
|
offset: int
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectDeleteResult(BaseModel):
|
||||||
|
deleted: bool
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.schemas.common import GeoJsonFeatureCollection
|
||||||
|
|
||||||
|
|
||||||
class QaProviderComparisonRequest(BaseModel):
|
class QaProviderComparisonRequest(BaseModel):
|
||||||
candidate_dataset_id: UUID
|
candidate_dataset_id: UUID
|
||||||
@@ -72,3 +75,40 @@ class QualityCheckList(BaseModel):
|
|||||||
total: int
|
total: int
|
||||||
limit: int
|
limit: int
|
||||||
offset: 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:
|
class AreaService:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def serialize_area(area: Area) -> dict:
|
def serialize_area(area: Area) -> dict:
|
||||||
payload = AreaRead.model_validate(area).model_dump()
|
geometry = to_shape(area.geometry) if area.geometry else None
|
||||||
payload["geometry"] = mapping(to_shape(area.geometry)) if area.geometry else None
|
return AreaRead.model_validate(
|
||||||
return payload
|
{
|
||||||
|
"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
|
@staticmethod
|
||||||
def list_areas(db: Session, project_id: uuid.UUID, limit: int = 50, offset: int = 0) -> tuple[list[Area], int]:
|
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:
|
def test_quality_check_evidence_geojson_api_uses_canonical_envelope(monkeypatch) -> None:
|
||||||
project_id = uuid4()
|
project_id = uuid4()
|
||||||
quality_check_id = uuid4()
|
quality_check_id = uuid4()
|
||||||
|
reference_dataset_id = uuid4()
|
||||||
payload = {
|
payload = {
|
||||||
"quality_check_id": str(quality_check_id),
|
"quality_check_id": str(quality_check_id),
|
||||||
"project_id": str(project_id),
|
"project_id": str(project_id),
|
||||||
|
"candidate_dataset_id": None,
|
||||||
|
"reference_dataset_id": str(reference_dataset_id),
|
||||||
|
"analysis_run_id": None,
|
||||||
"feature_count": 0,
|
"feature_count": 0,
|
||||||
|
"warnings": [],
|
||||||
"geojson": {"type": "FeatureCollection", "features": []},
|
"geojson": {"type": "FeatureCollection", "features": []},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -495,9 +495,25 @@ def test_dhmv_endpoints_use_canonical_envelopes(monkeypatch) -> None:
|
|||||||
"analyze",
|
"analyze",
|
||||||
lambda *_args, **_kwargs: {
|
lambda *_args, **_kwargs: {
|
||||||
"dataset_id": str(output_dataset_id),
|
"dataset_id": str(output_dataset_id),
|
||||||
|
"product_key": "dtm_1m",
|
||||||
|
"surface_model": "terrain",
|
||||||
|
"selection_bbox": lambert_bbox_payload().bbox.model_dump(),
|
||||||
"sample_count": 100,
|
"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"],
|
"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(
|
monkeypatch.setattr(
|
||||||
@@ -507,7 +523,25 @@ def test_dhmv_endpoints_use_canonical_envelopes(monkeypatch) -> None:
|
|||||||
"dataset_id": str(output_dataset_id),
|
"dataset_id": str(output_dataset_id),
|
||||||
"dataset_ids": [str(output_dataset_id)],
|
"dataset_ids": [str(output_dataset_id)],
|
||||||
"partition_count": 1,
|
"partition_count": 1,
|
||||||
|
"product_key": "dtm_1m",
|
||||||
|
"surface_model": "terrain",
|
||||||
|
"selection_bbox": lambert_bbox_payload().bbox.model_dump(),
|
||||||
"sample_count": 100,
|
"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
|
app.dependency_overrides[get_db] = lambda: db
|
||||||
|
|||||||
@@ -385,9 +385,27 @@ def test_flood_hazard_api_uses_canonical_envelopes(monkeypatch) -> None:
|
|||||||
"analyze",
|
"analyze",
|
||||||
lambda *_args, **_kwargs: {
|
lambda *_args, **_kwargs: {
|
||||||
"dataset_id": str(output_dataset_id),
|
"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,
|
"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"],
|
"unsupported_metrics": ["permanent_water_volume_m3"],
|
||||||
|
"limitation_message": "Scenario depth is not bathymetry.",
|
||||||
|
"generated_at": "2026-07-18T00:00:00Z",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
@@ -397,7 +415,27 @@ def test_flood_hazard_api_uses_canonical_envelopes(monkeypatch) -> None:
|
|||||||
"dataset_id": str(output_dataset_id),
|
"dataset_id": str(output_dataset_id),
|
||||||
"dataset_ids": [str(output_dataset_id)],
|
"dataset_ids": [str(output_dataset_id)],
|
||||||
"partition_count": 1,
|
"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_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
|
app.dependency_overrides[get_db] = lambda: db
|
||||||
|
|||||||
@@ -514,7 +514,29 @@ def test_api_uses_canonical_envelopes(monkeypatch) -> None:
|
|||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
ThematicRasterAnalysisService,
|
ThematicRasterAnalysisService,
|
||||||
"analyze",
|
"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
|
app.dependency_overrides[get_db] = lambda: db
|
||||||
try:
|
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")
|
routes = (ROOT / "backend/app/api/routes/datasets.py").read_text(encoding="utf-8")
|
||||||
contracts = (ROOT / "docs/API_CONTRACTS.md").read_text(encoding="utf-8")
|
contracts = (ROOT / "docs/API_CONTRACTS.md").read_text(encoding="utf-8")
|
||||||
|
|
||||||
for path in (
|
assert '"/datasets/raster/terrain/select",' in routes
|
||||||
"/datasets/raster/terrain/select",
|
assert "response_model=Envelope[TerrainSelectionResponse]" in routes
|
||||||
"/datasets/raster/flood-hazard/select",
|
assert '"/datasets/raster/flood-hazard/select",' in routes
|
||||||
):
|
assert "response_model=Envelope[FloodHazardSelectionResponse]" in routes
|
||||||
assert f'@router.post("{path}", response_model=dict)' in routes
|
assert "/datasets/raster/terrain/select" in contracts
|
||||||
assert path in contracts
|
assert "/datasets/raster/flood-hazard/select" in contracts
|
||||||
assert "envelope(TerrainAnalysisService.analyze_partitions" in routes
|
assert "envelope(TerrainAnalysisService.analyze_partitions" in routes
|
||||||
assert "envelope(FloodHazardAnalysisService.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: {
|
lambda: {
|
||||||
"source_key": "mdk_bcp_bathymetry",
|
"source_key": "mdk_bcp_bathymetry",
|
||||||
"status": "tls_error",
|
"status": "tls_error",
|
||||||
|
"configured_url": "https://example.invalid/wcs",
|
||||||
|
"tls_verified": False,
|
||||||
|
"capabilities_reachable": False,
|
||||||
"acquisition_supported": 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
|
app.dependency_overrides[get_db] = lambda: db
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ This document freezes the first API shape. Codex may add implementation details
|
|||||||
- GeoJSON accepted for geometries where possible.
|
- GeoJSON accepted for geometries where possible.
|
||||||
- Long processing tasks return a job or analysis run record instead of blocking.
|
- Long processing tasks return a job or analysis run record instead of blocking.
|
||||||
- Error responses use the shared `ApiError` schema.
|
- Error responses use the shared `ApiError` schema.
|
||||||
|
- Every successful JSON endpoint has a concrete Pydantic response model and
|
||||||
|
uses the canonical `{"data": ...}` envelope. Readiness runs an OpenAPI audit
|
||||||
|
that rejects free-form dictionary responses and envelope drift.
|
||||||
|
- The only successful non-envelope responses are `/health`, `/health/live`,
|
||||||
|
`/health/ready`, the four documented persisted-raster PNG endpoints and the
|
||||||
|
export artifact download endpoint.
|
||||||
|
|
||||||
## Shared schemas
|
## Shared schemas
|
||||||
|
|
||||||
|
|||||||
@@ -10516,3 +10516,33 @@ Decision:
|
|||||||
|
|
||||||
- RC-6 is complete. RC-7 critical API response typing and OpenAPI validation
|
- RC-6 is complete. RC-7 critical API response typing and OpenAPI validation
|
||||||
is active.
|
is active.
|
||||||
|
|
||||||
|
## 2026-07-18 - Belgium/North Sea RC-7 API contract hardening
|
||||||
|
|
||||||
|
Implemented:
|
||||||
|
|
||||||
|
- Replaced every remaining free-form successful JSON response model with a
|
||||||
|
concrete Pydantic schema while retaining the canonical `{"data": ...}`
|
||||||
|
payload shape.
|
||||||
|
- Added reusable generic envelope, list, pagination and GeoJSON schemas plus
|
||||||
|
concrete project, area, assistant, YOLO preflight and QA evidence models.
|
||||||
|
- Corrected Area serialization so persisted PostGIS geometries are converted
|
||||||
|
to GeoJSON before response validation.
|
||||||
|
- Added an executable OpenAPI contract audit and focused regression tests that
|
||||||
|
reject missing, free-dictionary or undocumented non-envelope responses.
|
||||||
|
- Tracked exactly eight deliberate exceptions: three health probes, four
|
||||||
|
persisted PNG endpoints and the streamed export download.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
|
||||||
|
- The API audit passed 124 implemented routes and 228 OpenAPI component
|
||||||
|
schemas.
|
||||||
|
- Repository readiness passed backend compilation, 1,008 backend tests,
|
||||||
|
Alembic head `202607160001`, frontend typecheck and production build.
|
||||||
|
- Offline `alembic upgrade head --sql` completed and generated 31,548 bytes of
|
||||||
|
migration evidence.
|
||||||
|
|
||||||
|
Decision:
|
||||||
|
|
||||||
|
- RC-7 is complete. RC-8 automated frontend and browser release journeys are
|
||||||
|
active.
|
||||||
|
|||||||
@@ -150,8 +150,8 @@ editions and licences must still pass source-specific probes before activation.
|
|||||||
| RC-4 | complete | national/maritime scope and provider coverage contracts |
|
| RC-4 | complete | national/maritime scope and provider coverage contracts |
|
||||||
| RC-5 | complete | deployment, secrets, configuration, fresh install and rollback |
|
| RC-5 | complete | deployment, secrets, configuration, fresh install and rollback |
|
||||||
| RC-6 | complete | complete CI, dependency and supply-chain gates |
|
| RC-6 | complete | complete CI, dependency and supply-chain gates |
|
||||||
| RC-7 | in progress | critical API envelope typing and contract validation |
|
| RC-7 | complete | critical API envelope typing and contract validation |
|
||||||
| RC-8 | pending | frontend and browser E2E release journeys |
|
| RC-8 | in progress | frontend and browser E2E release journeys |
|
||||||
| RC-9 | pending | loading, accessibility and performance hardening |
|
| RC-9 | pending | loading, accessibility and performance hardening |
|
||||||
| RC-10 | pending | retention, cleanup and national data operations |
|
| RC-10 | pending | retention, cleanup and national data operations |
|
||||||
| RC-11 | pending | final package, upgrade proof, release tag and handoff |
|
| RC-11 | pending | final package, upgrade proof, release tag and handoff |
|
||||||
@@ -405,6 +405,13 @@ UID 999.
|
|||||||
|
|
||||||
## RC-7 - Critical API contract hardening
|
## RC-7 - Critical API contract hardening
|
||||||
|
|
||||||
|
**State: complete.** All successful JSON routes now publish concrete OpenAPI
|
||||||
|
response schemas. The contract audit covers 124 implemented routes and 228
|
||||||
|
component schemas; the eight non-envelope operations are the three health
|
||||||
|
probes, four persisted PNG responses and the export download. Readiness passed
|
||||||
|
with 1,008 backend tests, frontend typecheck/build, one Alembic head and
|
||||||
|
offline migration SQL generation.
|
||||||
|
|
||||||
### Work
|
### Work
|
||||||
|
|
||||||
- replace `response_model=dict` first on health, projects, areas, datasets,
|
- replace `response_model=dict` first on health, projects, areas, datasets,
|
||||||
|
|||||||
+1
-1
@@ -25,7 +25,7 @@ maritieme zones.
|
|||||||
- [x] RC-5: secrets/configuratie/uploadlimieten/immutable deploy en rollback
|
- [x] RC-5: secrets/configuratie/uploadlimieten/immutable deploy en rollback
|
||||||
bewijzen.
|
bewijzen.
|
||||||
- [x] RC-6: volledige CI, dependency-audit, containerscan en SBOM toevoegen.
|
- [x] RC-6: volledige CI, dependency-audit, containerscan en SBOM toevoegen.
|
||||||
- [ ] RC-7: kritieke API-routes concrete responsemodellen geven.
|
- [x] RC-7: kritieke API-routes concrete responsemodellen geven.
|
||||||
- [ ] RC-8: echte frontend- en browser-E2E-releaseflows toevoegen.
|
- [ ] RC-8: echte frontend- en browser-E2E-releaseflows toevoegen.
|
||||||
- [ ] RC-9: loading, toegankelijkheid, widescreen/mobile en performance afronden.
|
- [ ] RC-9: loading, toegankelijkheid, widescreen/mobile en performance afronden.
|
||||||
- [ ] RC-10: dataretentie, diskdruk en veilige cleanup operationaliseren.
|
- [ ] RC-10: dataretentie, diskdruk en veilige cleanup operationaliseren.
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ ALLOWED_NON_ENVELOPE_ENDPOINTS = {
|
|||||||
("GET", "/health/ready"),
|
("GET", "/health/ready"),
|
||||||
("GET", "/api/v1/exports/{export_id}/download"),
|
("GET", "/api/v1/exports/{export_id}/download"),
|
||||||
("GET", "/api/v1/projects/{project_id}/datasets/{dataset_id}/raster/image"),
|
("GET", "/api/v1/projects/{project_id}/datasets/{dataset_id}/raster/image"),
|
||||||
|
("GET", "/api/v1/projects/{project_id}/datasets/{dataset_id}/raster/terrain/image"),
|
||||||
|
("GET", "/api/v1/projects/{project_id}/datasets/{dataset_id}/raster/flood-hazard/image"),
|
||||||
|
("GET", "/api/v1/projects/{project_id}/datasets/{dataset_id}/raster/thematic/image"),
|
||||||
}
|
}
|
||||||
|
|
||||||
IGNORED_OPENAPI_PATHS = {
|
IGNORED_OPENAPI_PATHS = {
|
||||||
@@ -25,11 +28,14 @@ IGNORED_OPENAPI_PATHS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _load_app_routes() -> set[tuple[str, str]]:
|
def _load_openapi() -> dict:
|
||||||
sys.path.insert(0, str(BACKEND))
|
sys.path.insert(0, str(BACKEND))
|
||||||
from app.main import create_app
|
from app.main import create_app
|
||||||
|
|
||||||
schema = create_app().openapi()
|
return create_app().openapi()
|
||||||
|
|
||||||
|
|
||||||
|
def _load_app_routes(schema: dict) -> set[tuple[str, str]]:
|
||||||
routes: set[tuple[str, str]] = set()
|
routes: set[tuple[str, str]] = set()
|
||||||
for path, path_item in schema.get("paths", {}).items():
|
for path, path_item in schema.get("paths", {}).items():
|
||||||
if path in IGNORED_OPENAPI_PATHS or not isinstance(path_item, dict):
|
if path in IGNORED_OPENAPI_PATHS or not isinstance(path_item, dict):
|
||||||
@@ -41,13 +47,64 @@ def _load_app_routes() -> set[tuple[str, str]]:
|
|||||||
return routes
|
return routes
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_schema(openapi: dict, schema: dict) -> dict:
|
||||||
|
reference = schema.get("$ref")
|
||||||
|
if not isinstance(reference, str):
|
||||||
|
return schema
|
||||||
|
prefix = "#/components/schemas/"
|
||||||
|
if not reference.startswith(prefix):
|
||||||
|
return {}
|
||||||
|
return openapi.get("components", {}).get("schemas", {}).get(reference[len(prefix):], {})
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_response_contracts(openapi: dict) -> list[str]:
|
||||||
|
errors: list[str] = []
|
||||||
|
for path, path_item in openapi.get("paths", {}).items():
|
||||||
|
if path in IGNORED_OPENAPI_PATHS or not isinstance(path_item, dict):
|
||||||
|
continue
|
||||||
|
for method, operation in path_item.items():
|
||||||
|
normalized_method = method.upper()
|
||||||
|
if normalized_method not in {"GET", "POST", "PATCH", "DELETE"}:
|
||||||
|
continue
|
||||||
|
if (normalized_method, path) in ALLOWED_NON_ENVELOPE_ENDPOINTS:
|
||||||
|
continue
|
||||||
|
responses = operation.get("responses", {})
|
||||||
|
success_responses = [
|
||||||
|
response
|
||||||
|
for status_code, response in responses.items()
|
||||||
|
if str(status_code).startswith("2")
|
||||||
|
]
|
||||||
|
if not success_responses:
|
||||||
|
errors.append(f"Missing successful OpenAPI response: {normalized_method} {path}")
|
||||||
|
continue
|
||||||
|
for response in success_responses:
|
||||||
|
schema = (
|
||||||
|
response.get("content", {})
|
||||||
|
.get("application/json", {})
|
||||||
|
.get("schema", {})
|
||||||
|
)
|
||||||
|
if not schema:
|
||||||
|
errors.append(f"Missing concrete JSON response schema: {normalized_method} {path}")
|
||||||
|
continue
|
||||||
|
resolved = _resolve_schema(openapi, schema)
|
||||||
|
if resolved.get("additionalProperties") is True:
|
||||||
|
errors.append(f"Untyped dictionary response schema: {normalized_method} {path}")
|
||||||
|
continue
|
||||||
|
if path not in {"/health", "/health/live", "/health/ready"}:
|
||||||
|
properties = resolved.get("properties", {})
|
||||||
|
if "data" not in properties:
|
||||||
|
errors.append(f"Missing canonical data envelope: {normalized_method} {path}")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
def _load_documented_routes() -> set[tuple[str, str]]:
|
def _load_documented_routes() -> set[tuple[str, str]]:
|
||||||
docs = DOCS.read_text(encoding="utf-8")
|
docs = DOCS.read_text(encoding="utf-8")
|
||||||
return set(re.findall(r"###\s+(GET|POST|PATCH|DELETE)\s+`([^`]+)`", docs))
|
return set(re.findall(r"###\s+(GET|POST|PATCH|DELETE)\s+`([^`]+)`", docs))
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
implemented_routes = _load_app_routes()
|
openapi = _load_openapi()
|
||||||
|
implemented_routes = _load_app_routes(openapi)
|
||||||
documented_routes = _load_documented_routes()
|
documented_routes = _load_documented_routes()
|
||||||
|
|
||||||
missing_docs = sorted(implemented_routes - documented_routes)
|
missing_docs = sorted(implemented_routes - documented_routes)
|
||||||
@@ -61,6 +118,7 @@ def main() -> None:
|
|||||||
errors.append(f"Documented API route is not implemented: {method} {path}")
|
errors.append(f"Documented API route is not implemented: {method} {path}")
|
||||||
for method, path in missing_non_envelope:
|
for method, path in missing_non_envelope:
|
||||||
errors.append(f"Allowed non-envelope endpoint is not implemented: {method} {path}")
|
errors.append(f"Allowed non-envelope endpoint is not implemented: {method} {path}")
|
||||||
|
errors.extend(_validate_response_contracts(openapi))
|
||||||
|
|
||||||
if errors:
|
if errors:
|
||||||
raise SystemExit("\n".join(errors))
|
raise SystemExit("\n".join(errors))
|
||||||
|
|||||||
Reference in New Issue
Block a user