Complete operational map result workflow
This commit is contained in:
@@ -7,7 +7,7 @@ from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.schemas.export import GeoJsonExportRequest, MetadataExportRequest, ReportExportRequest
|
||||
from app.schemas.export import GeoJsonExportRequest, MapResultExportRequest, MetadataExportRequest, ReportExportRequest
|
||||
from app.services.export_service import ExportService
|
||||
from app.utils.response import envelope
|
||||
|
||||
@@ -22,6 +22,7 @@ def export_geojson(payload: GeoJsonExportRequest, db: Session = Depends(get_db))
|
||||
db,
|
||||
payload.dataset_id,
|
||||
payload.bbox.model_dump(),
|
||||
area_id=payload.area_id,
|
||||
limit=payload.limit,
|
||||
name=payload.name,
|
||||
).model_dump(mode="json")
|
||||
@@ -49,6 +50,11 @@ def export_project_report(payload: ReportExportRequest, db: Session = Depends(ge
|
||||
return envelope(ExportService.export_project_report(db, payload.project_id, payload.name).model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.post("/map-result", response_model=dict)
|
||||
def export_map_result(payload: MapResultExportRequest, db: Session = Depends(get_db)):
|
||||
return envelope(ExportService.export_map_result(db, payload).model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/exports", response_model=dict)
|
||||
def list_project_exports(
|
||||
project_id: UUID,
|
||||
|
||||
@@ -17,9 +17,10 @@ router = APIRouter(prefix="/projects", tags=["projects"])
|
||||
def list_projects(
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
name: str | None = Query(default=None, min_length=1, max_length=255),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
projects, total = ProjectService.list_projects(db, limit=limit, offset=offset)
|
||||
projects, total = ProjectService.list_projects(db, limit=limit, offset=offset, name=name)
|
||||
return envelope({"items": [ProjectRead.model_validate(item).model_dump() for item in projects], "total": total, "limit": limit, "offset": offset})
|
||||
|
||||
|
||||
|
||||
@@ -10,11 +10,13 @@ from app.schemas.operations import VectorSelectionBBox
|
||||
|
||||
|
||||
ExportKind = Literal["dataset", "detection_run", "segmentation_run", "vector_selection"]
|
||||
MapResultMode = Literal["current", "evolution"]
|
||||
|
||||
|
||||
class GeoJsonExportRequest(BaseModel):
|
||||
dataset_id: UUID | None = None
|
||||
analysis_run_id: UUID | None = None
|
||||
area_id: UUID | None = None
|
||||
export_kind: ExportKind = "dataset"
|
||||
name: str | None = None
|
||||
bbox: VectorSelectionBBox | None = None
|
||||
@@ -44,6 +46,32 @@ class ReportExportRequest(BaseModel):
|
||||
name: str | None = None
|
||||
|
||||
|
||||
class MapResultExportRequest(BaseModel):
|
||||
project_id: UUID
|
||||
mode: MapResultMode
|
||||
bbox: VectorSelectionBBox
|
||||
dataset_id: UUID | None = None
|
||||
earlier_dataset_id: UUID | None = None
|
||||
later_dataset_id: UUID | None = None
|
||||
area_id: UUID | None = None
|
||||
partitioned: bool = False
|
||||
product_key: str | None = None
|
||||
theme_id: str | None = None
|
||||
name: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_map_target(self) -> "MapResultExportRequest":
|
||||
if self.mode == "current" and self.dataset_id is None:
|
||||
raise ValueError("dataset_id is required for current map-result exports")
|
||||
if self.mode == "evolution" and (
|
||||
self.earlier_dataset_id is None or self.later_dataset_id is None
|
||||
):
|
||||
raise ValueError("earlier_dataset_id and later_dataset_id are required for evolution exports")
|
||||
if self.partitioned and not self.product_key:
|
||||
raise ValueError("product_key is required for partitioned raster exports")
|
||||
return self
|
||||
|
||||
|
||||
class ExportRead(BaseModel):
|
||||
id: UUID
|
||||
project_id: UUID
|
||||
|
||||
@@ -11,20 +11,199 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.errors import AppError
|
||||
from app.models import AnalysisRun, Area, Dataset, Export, Project, QualityCheck
|
||||
from app.schemas.export import ExportContentResponse, ExportCreateResponse, ExportListResponse, ExportRead
|
||||
from app.schemas.dhmv import TerrainPartitionSelectionRequest, TerrainSelectionRequest
|
||||
from app.schemas.export import (
|
||||
ExportContentResponse,
|
||||
ExportCreateResponse,
|
||||
ExportListResponse,
|
||||
ExportRead,
|
||||
MapResultExportRequest,
|
||||
)
|
||||
from app.schemas.flood_hazard import FloodHazardPartitionSelectionRequest, FloodHazardSelectionRequest
|
||||
from app.schemas.temporal import TemporalComparisonRequest
|
||||
from app.schemas.thematic_raster import ThematicRasterSelectionRequest
|
||||
from app.services.dataset_service import DatasetService
|
||||
from app.services.detection_service import DetectionService
|
||||
from app.services.flood_hazard_analysis_service import FloodHazardAnalysisService
|
||||
from app.services.segmentation_service import SegmentationService
|
||||
from app.services.storage_service import StorageService
|
||||
from app.services.temporal_analysis_service import TemporalAnalysisService
|
||||
from app.services.terrain_analysis_service import TerrainAnalysisService
|
||||
from app.services.thematic_raster_analysis_service import ThematicRasterAnalysisService
|
||||
from app.services.vector_feature_service import VectorFeatureService
|
||||
|
||||
|
||||
class ExportService:
|
||||
@staticmethod
|
||||
def export_map_result(
|
||||
db: Session,
|
||||
payload: MapResultExportRequest,
|
||||
) -> ExportCreateResponse:
|
||||
if payload.mode == "evolution":
|
||||
comparison = TemporalAnalysisService.compare(
|
||||
db,
|
||||
project_id=payload.project_id,
|
||||
payload=TemporalComparisonRequest(
|
||||
earlier_dataset_id=payload.earlier_dataset_id,
|
||||
later_dataset_id=payload.later_dataset_id,
|
||||
bbox=payload.bbox,
|
||||
area_id=payload.area_id,
|
||||
),
|
||||
)
|
||||
content = comparison.model_dump(mode="json")
|
||||
target_id = str(payload.later_dataset_id)
|
||||
filename = ExportService._filename(
|
||||
payload.name,
|
||||
f"{target_id}-evolution.json",
|
||||
".json",
|
||||
)
|
||||
export_path = StorageService.dataset_export_path(
|
||||
str(payload.project_id),
|
||||
target_id,
|
||||
filename,
|
||||
)
|
||||
metadata = {
|
||||
"source": "map_evolution",
|
||||
"project_id": str(payload.project_id),
|
||||
"earlier_dataset_id": str(payload.earlier_dataset_id),
|
||||
"later_dataset_id": str(payload.later_dataset_id),
|
||||
"selection_bbox": payload.bbox.model_dump(mode="json"),
|
||||
"selection_area_id": str(payload.area_id) if payload.area_id else None,
|
||||
"theme_id": payload.theme_id,
|
||||
"server_recomputed": True,
|
||||
}
|
||||
export = ExportService._write_json_export(
|
||||
db,
|
||||
project_id=payload.project_id,
|
||||
analysis_run_id=None,
|
||||
export_type="map_evolution_json",
|
||||
storage_path=export_path,
|
||||
content=content,
|
||||
metadata=metadata,
|
||||
)
|
||||
return ExportService._create_response(export)
|
||||
|
||||
dataset = db.get(Dataset, payload.dataset_id)
|
||||
if not dataset or dataset.project_id != payload.project_id:
|
||||
raise AppError(code="DATASET_NOT_FOUND", message="Dataset not found", status_code=404)
|
||||
if dataset.dataset_type in DatasetService.VECTOR_TYPES:
|
||||
return ExportService.export_vector_selection_geojson(
|
||||
db,
|
||||
dataset.id,
|
||||
payload.bbox.model_dump(mode="json"),
|
||||
area_id=payload.area_id,
|
||||
name=payload.name,
|
||||
limit=1000,
|
||||
)
|
||||
if dataset.dataset_type != "raster":
|
||||
raise AppError(
|
||||
code="INVALID_DATASET_TYPE",
|
||||
message="Map-result export requires a vector or governed raster dataset",
|
||||
details={"dataset_type": dataset.dataset_type},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
if dataset.source_name == "digitaal_vlaanderen_dhmv":
|
||||
result = (
|
||||
TerrainAnalysisService.analyze_partitions(
|
||||
db,
|
||||
payload.project_id,
|
||||
TerrainPartitionSelectionRequest(
|
||||
bbox=payload.bbox,
|
||||
area_id=payload.area_id,
|
||||
product_key=payload.product_key or "dtm_1m",
|
||||
),
|
||||
)
|
||||
if payload.partitioned
|
||||
else TerrainAnalysisService.analyze(
|
||||
db,
|
||||
payload.project_id,
|
||||
dataset.id,
|
||||
TerrainSelectionRequest(bbox=payload.bbox, area_id=payload.area_id),
|
||||
)
|
||||
)
|
||||
elif dataset.source_name == "vmm_flood_hazard":
|
||||
result = (
|
||||
FloodHazardAnalysisService.analyze_partitions(
|
||||
db,
|
||||
payload.project_id,
|
||||
FloodHazardPartitionSelectionRequest(
|
||||
bbox=payload.bbox,
|
||||
area_id=payload.area_id,
|
||||
product_key=payload.product_key or "pluviaal_current_t100",
|
||||
),
|
||||
)
|
||||
if payload.partitioned
|
||||
else FloodHazardAnalysisService.analyze(
|
||||
db,
|
||||
payload.project_id,
|
||||
dataset.id,
|
||||
FloodHazardSelectionRequest(bbox=payload.bbox, area_id=payload.area_id),
|
||||
)
|
||||
)
|
||||
elif dataset.source_name == "department_omgeving_thematic_raster":
|
||||
result = ThematicRasterAnalysisService.analyze(
|
||||
db,
|
||||
payload.project_id,
|
||||
dataset.id,
|
||||
ThematicRasterSelectionRequest(bbox=payload.bbox, area_id=payload.area_id),
|
||||
)
|
||||
else:
|
||||
raise AppError(
|
||||
code="MAP_RESULT_EXPORT_UNSUPPORTED",
|
||||
message="This raster source does not expose a governed map-result export",
|
||||
details={"source_name": dataset.source_name},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
content = {
|
||||
"mode": "current",
|
||||
"theme_id": payload.theme_id,
|
||||
"dataset": {
|
||||
"id": str(dataset.id),
|
||||
"name": dataset.name,
|
||||
"source_name": dataset.source_name,
|
||||
},
|
||||
"result": result,
|
||||
}
|
||||
filename = ExportService._filename(
|
||||
payload.name,
|
||||
f"{dataset.id}-map-analysis.json",
|
||||
".json",
|
||||
)
|
||||
export_path = StorageService.dataset_export_path(
|
||||
str(payload.project_id),
|
||||
str(dataset.id),
|
||||
filename,
|
||||
)
|
||||
metadata = {
|
||||
"source": "map_analysis",
|
||||
"project_id": str(payload.project_id),
|
||||
"dataset_id": str(dataset.id),
|
||||
"selection_bbox": payload.bbox.model_dump(mode="json"),
|
||||
"selection_area_id": str(payload.area_id) if payload.area_id else None,
|
||||
"theme_id": payload.theme_id,
|
||||
"partitioned": payload.partitioned,
|
||||
"product_key": payload.product_key,
|
||||
"server_recomputed": True,
|
||||
}
|
||||
export = ExportService._write_json_export(
|
||||
db,
|
||||
project_id=payload.project_id,
|
||||
analysis_run_id=None,
|
||||
export_type="map_analysis_json",
|
||||
storage_path=export_path,
|
||||
content=content,
|
||||
metadata=metadata,
|
||||
)
|
||||
return ExportService._create_response(export)
|
||||
|
||||
@staticmethod
|
||||
def export_vector_selection_geojson(
|
||||
db: Session,
|
||||
dataset_id: uuid.UUID,
|
||||
bbox: dict[str, Any],
|
||||
area_id: uuid.UUID | None = None,
|
||||
limit: int = 250,
|
||||
name: str | None = None,
|
||||
) -> ExportCreateResponse:
|
||||
@@ -39,7 +218,28 @@ class ExportService:
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
selection = VectorFeatureService.select_features_by_bbox(db, dataset_id=dataset_id, bbox=bbox, limit=limit)
|
||||
selection_kwargs: dict[str, Any] = {
|
||||
"dataset_id": dataset_id,
|
||||
"bbox": bbox,
|
||||
"limit": limit,
|
||||
}
|
||||
if area_id is not None:
|
||||
area = db.get(Area, area_id)
|
||||
if not area or area.project_id != dataset.project_id:
|
||||
raise AppError(code="AREA_NOT_FOUND", message="Area not found", status_code=404)
|
||||
full_dataset_area = VectorFeatureService.can_use_full_area_fast_path(dataset, area.id)
|
||||
preclipped_partition_filter = (
|
||||
None
|
||||
if full_dataset_area
|
||||
else VectorFeatureService.preclipped_partition_filter(dataset, getattr(area, "name", None))
|
||||
)
|
||||
selection_kwargs.update(
|
||||
selection_geometry=area.geometry,
|
||||
selection_area_id=area.id,
|
||||
full_dataset_area=full_dataset_area,
|
||||
preclipped_partition_filter=preclipped_partition_filter,
|
||||
)
|
||||
selection = VectorFeatureService.select_features_by_bbox(db, **selection_kwargs)
|
||||
filename = ExportService._filename(name, f"{dataset.id}-selection.geojson", ".geojson")
|
||||
export_path = StorageService.dataset_export_path(str(dataset.project_id), str(dataset.id), filename)
|
||||
metadata = {
|
||||
@@ -48,6 +248,7 @@ class ExportService:
|
||||
"dataset_id": str(dataset.id),
|
||||
"dataset_type": dataset.dataset_type,
|
||||
"selection_bbox": selection["selection_bbox"],
|
||||
"selection_area_id": selection.get("selection_area_id"),
|
||||
"feature_count": selection["feature_count"],
|
||||
"limit": selection["limit"],
|
||||
"truncated": selection["truncated"],
|
||||
|
||||
@@ -11,8 +11,16 @@ from app.schemas.project import ProjectCreate, ProjectRead, ProjectUpdate
|
||||
|
||||
class ProjectService:
|
||||
@staticmethod
|
||||
def list_projects(db: Session, limit: int = 50, offset: int = 0) -> tuple[list[ProjectRead], int]:
|
||||
query = db.query(Project).filter(Project.status != "deleted").order_by(Project.created_at.desc())
|
||||
def list_projects(
|
||||
db: Session,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
name: str | None = None,
|
||||
) -> tuple[list[ProjectRead], int]:
|
||||
query = db.query(Project).filter(Project.status != "deleted")
|
||||
if name:
|
||||
query = query.filter(Project.name == name.strip())
|
||||
query = query.order_by(Project.created_at.desc())
|
||||
total = query.count()
|
||||
items = query.offset(offset).limit(limit).all()
|
||||
return [ProjectRead.model_validate(item) for item in items], total
|
||||
|
||||
Reference in New Issue
Block a user