feat(provenance): govern source snapshots and data inputs

This commit is contained in:
Jens
2026-08-01 23:46:17 +02:00
parent cebeb5f3b4
commit 5b3c17b494
96 changed files with 20156 additions and 351 deletions
+34
View File
@@ -23,6 +23,7 @@ from app.schemas.flood_hazard import FloodHazardPartitionSelectionRequest, Flood
from app.schemas.temporal import TemporalComparisonRequest
from app.schemas.thematic_raster import ThematicRasterSelectionRequest
from app.services.dataset_service import DatasetService
from app.services.dataset_consumption_gate_service import DatasetConsumptionGate
from app.services.detection_service import DetectionService
from app.services.flood_hazard_analysis_service import FloodHazardAnalysisService
from app.services.segmentation_service import SegmentationService
@@ -34,12 +35,39 @@ from app.services.vector_feature_service import VectorFeatureService
class ExportService:
@staticmethod
def _assert_run_source_dataset_exportable(db: Session, run: AnalysisRun) -> Dataset:
"""Block an output export when its persisted source dataset is unsafe."""
if not run.dataset_id:
raise AppError(
code="DATASET_PROVENANCE_INCOMPLETE",
message="Analysis output cannot be exported without a persisted source dataset.",
status_code=409,
)
dataset = db.get(Dataset, run.dataset_id)
if not dataset or dataset.project_id != run.project_id:
raise AppError(code="DATASET_NOT_FOUND", message="Analysis source dataset not found", status_code=404)
DatasetConsumptionGate.assert_eligible(dataset, purpose="export")
return dataset
@staticmethod
def export_map_result(
db: Session,
payload: MapResultExportRequest,
) -> ExportCreateResponse:
if payload.mode == "evolution":
earlier_dataset = db.get(Dataset, payload.earlier_dataset_id)
later_dataset = db.get(Dataset, payload.later_dataset_id)
if (
not earlier_dataset
or not later_dataset
or earlier_dataset.project_id != payload.project_id
or later_dataset.project_id != payload.project_id
):
raise AppError(code="DATASET_NOT_FOUND", message="Temporal export dataset not found", status_code=404)
DatasetConsumptionGate.assert_eligible(earlier_dataset, purpose="export")
DatasetConsumptionGate.assert_eligible(later_dataset, purpose="export")
comparison = TemporalAnalysisService.compare(
db,
project_id=payload.project_id,
@@ -86,6 +114,7 @@ class ExportService:
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)
DatasetConsumptionGate.assert_eligible(dataset, purpose="export")
if dataset.dataset_type in DatasetService.VECTOR_TYPES:
if payload.partitioned:
return ExportService.export_partitioned_vector_selection_geojson(
@@ -219,6 +248,7 @@ class ExportService:
limit: int = 1000,
name: str | None = None,
) -> ExportCreateResponse:
DatasetConsumptionGate.assert_eligible(dataset, purpose="export")
if dataset.source_name != "vmm_vha_bathymetry_profiles" or partition_scope_key != "flanders":
raise AppError(
code="PARTITIONED_VECTOR_EXPORT_UNSUPPORTED",
@@ -308,6 +338,7 @@ class ExportService:
details={"dataset_type": dataset.dataset_type},
status_code=400,
)
DatasetConsumptionGate.assert_eligible(dataset, purpose="export")
selection_kwargs: dict[str, Any] = {
"dataset_id": dataset_id,
@@ -374,6 +405,7 @@ class ExportService:
details={"dataset_type": dataset.dataset_type},
status_code=400,
)
DatasetConsumptionGate.assert_eligible(dataset, purpose="export")
feature_collection = DatasetService.get_dataset_geojson(db, dataset_id)
filename = ExportService._filename(name, f"{dataset.id}.geojson", ".geojson")
@@ -401,6 +433,7 @@ class ExportService:
run = db.get(AnalysisRun, analysis_run_id)
if not run or run.analysis_type != "detection":
raise AppError(code="DETECTION_RUN_NOT_FOUND", message="Detection run not found", status_code=404)
ExportService._assert_run_source_dataset_exportable(db, run)
feature_collection = DetectionService.detections_to_geojson(db, analysis_run_id=analysis_run_id)
filename = ExportService._filename(name, f"{run.id}-detections.geojson", ".geojson")
@@ -428,6 +461,7 @@ class ExportService:
run = db.get(AnalysisRun, analysis_run_id)
if not run or run.analysis_type != "segmentation":
raise AppError(code="SEGMENTATION_RUN_NOT_FOUND", message="Segmentation run not found", status_code=404)
ExportService._assert_run_source_dataset_exportable(db, run)
feature_collection = SegmentationService.segmentations_to_geojson(db, analysis_run_id=analysis_run_id)
filename = ExportService._filename(name, f"{run.id}-segmentations.geojson", ".geojson")