Complete operational map result workflow
This commit is contained in:
+5
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
FastAPI backend for GeoIntel Kempen Foundation Sprints.
|
||||
|
||||
The map-first explorer uses the existing persisted vector selection endpoint. Its bounded GeoJSON preview reports `feature_count`, while `total_feature_count` reports the exact PostGIS intersection count before the 1,000-feature response cap. This keeps municipality-scale analysis honest without sending unbounded geometry to the browser.
|
||||
The map-first explorer uses the existing persisted vector selection endpoint. Its bounded GeoJSON preview reports `feature_count`, while `total_feature_count` reports the exact PostGIS intersection count before the 1,000-feature response cap. Drawn/manual selections send only their bbox; `area_id` is reserved for the explicit full-work-area operation. This keeps municipality-scale analysis honest without sending unbounded geometry to the browser.
|
||||
|
||||
## Scope implemented
|
||||
- Project CRUD
|
||||
@@ -188,12 +188,16 @@ bash scripts/live_migration_smoke.sh
|
||||
- Added project metadata JSON export for project, dataset and QA/QC summary state.
|
||||
- Added export read/list/content endpoints:
|
||||
- `POST /api/v1/exports/geojson`
|
||||
- `POST /api/v1/exports/map-result`
|
||||
- `POST /api/v1/exports/metadata`
|
||||
- `GET /api/v1/exports/projects/{project_id}/exports`
|
||||
- `GET /api/v1/exports/{export_id}`
|
||||
- `GET /api/v1/exports/{export_id}/content`
|
||||
- Exported detection and segmentation GeoJSON is generated from persisted first-class geometry rows.
|
||||
- No new migrations, product lines, live providers or AI dependencies are introduced by this export pass.
|
||||
- Map-result exports recompute current governed vector/raster selections or
|
||||
historical comparisons on the backend before persisting the artifact. Client
|
||||
metrics are never accepted as authoritative export content.
|
||||
- Old offline demo export artifacts can be inspected with `python scripts/cleanup_demo_artifacts.py`
|
||||
and removed only with an explicit `--apply`. The script keeps the newest exports
|
||||
per demo project, refuses to delete files outside `STORAGE_ROOT`, and blocks
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,12 +2,13 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
from app.models import Dataset, Export
|
||||
from app.models import Area, Dataset, Export
|
||||
from app.schemas.export import ExportCreateResponse
|
||||
from app.services.export_service import ExportService
|
||||
from app.services.storage_service import StorageService
|
||||
@@ -91,23 +92,31 @@ def test_vector_selection_geojson_export_persists_handoff_artifact(tmp_path, mon
|
||||
def test_vector_selection_geojson_export_endpoint_returns_canonical_envelope(monkeypatch) -> None:
|
||||
export_id = uuid4()
|
||||
dataset_id = uuid4()
|
||||
area_id = uuid4()
|
||||
expected_bbox = {"min_x": 4.9, "min_y": 50.9, "max_x": 5.2, "max_y": 51.2, "crs": "EPSG:4326"}
|
||||
captured: dict = {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
ExportService,
|
||||
"export_vector_selection_geojson",
|
||||
lambda *_args, **_kwargs: ExportCreateResponse(
|
||||
def fake_export(*_args, **kwargs):
|
||||
captured.update(kwargs)
|
||||
return ExportCreateResponse(
|
||||
export_id=export_id,
|
||||
path="storage/exports/demo-selection.geojson",
|
||||
status="ready",
|
||||
export_type="vector_selection_geojson",
|
||||
metadata_json={"source": "vector_selection", "selection_bbox": expected_bbox},
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
monkeypatch.setattr(ExportService, "export_vector_selection_geojson", fake_export)
|
||||
|
||||
response = TestClient(app).post(
|
||||
"/api/v1/exports/geojson",
|
||||
json={"dataset_id": str(dataset_id), "export_kind": "vector_selection", "bbox": expected_bbox, "limit": 250},
|
||||
json={
|
||||
"dataset_id": str(dataset_id),
|
||||
"area_id": str(area_id),
|
||||
"export_kind": "vector_selection",
|
||||
"bbox": expected_bbox,
|
||||
"limit": 250,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -116,6 +125,55 @@ def test_vector_selection_geojson_export_endpoint_returns_canonical_envelope(mon
|
||||
assert payload["data"]["export_id"] == str(export_id)
|
||||
assert payload["data"]["export_type"] == "vector_selection_geojson"
|
||||
assert payload["data"]["metadata_json"]["selection_bbox"] == expected_bbox
|
||||
assert captured["area_id"] == area_id
|
||||
|
||||
|
||||
def test_vector_selection_export_uses_exact_area_scope_when_requested(tmp_path, monkeypatch) -> None:
|
||||
project_id = uuid4()
|
||||
dataset_id = uuid4()
|
||||
area_id = uuid4()
|
||||
dataset = Dataset(
|
||||
id=dataset_id,
|
||||
project_id=project_id,
|
||||
name="regional-buildings.geojson",
|
||||
dataset_type="vector",
|
||||
source="fixture",
|
||||
status="ready",
|
||||
)
|
||||
area_geometry = object()
|
||||
area = SimpleNamespace(id=area_id, project_id=project_id, name="Gemeente Mol", geometry=area_geometry)
|
||||
db = FakeSession({(Dataset, dataset_id): dataset, (Area, area_id): area})
|
||||
selection_bbox = {"min_x": 5.0, "min_y": 51.1, "max_x": 5.2, "max_y": 51.3, "crs": "EPSG:4326"}
|
||||
captured: dict = {}
|
||||
selection_payload = {
|
||||
"selection_bbox": selection_bbox,
|
||||
"selection_area_id": str(area_id),
|
||||
"feature_count": 0,
|
||||
"limit": 250,
|
||||
"truncated": False,
|
||||
"geojson": {"type": "FeatureCollection", "features": []},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(StorageService, "dataset_export_path", lambda *_args: str(tmp_path / "selection.geojson"))
|
||||
monkeypatch.setattr(VectorFeatureService, "can_use_full_area_fast_path", lambda *_args: True)
|
||||
|
||||
def fake_select(*_args, **kwargs):
|
||||
captured.update(kwargs)
|
||||
return selection_payload
|
||||
|
||||
monkeypatch.setattr(VectorFeatureService, "select_features_by_bbox", fake_select)
|
||||
|
||||
response = ExportService.export_vector_selection_geojson(
|
||||
db,
|
||||
dataset_id,
|
||||
selection_bbox,
|
||||
area_id=area_id,
|
||||
)
|
||||
|
||||
assert captured["selection_geometry"] is area_geometry
|
||||
assert captured["selection_area_id"] == area_id
|
||||
assert captured["full_dataset_area"] is True
|
||||
assert response.metadata_json["selection_area_id"] == str(area_id)
|
||||
|
||||
|
||||
def test_frontend_exposes_map_selection_export_action() -> None:
|
||||
@@ -127,6 +185,7 @@ def test_frontend_exposes_map_selection_export_action() -> None:
|
||||
|
||||
assert "'vector_selection'" in types
|
||||
assert "bbox?: VectorSelectionBBox" in exports_api
|
||||
assert "area_id?: string" in exports_api
|
||||
assert "exportMapSelectionGeoJson" in export_hook
|
||||
assert "vector_selection" in export_hook
|
||||
assert "Save area export" in map_workspace
|
||||
|
||||
@@ -36,7 +36,10 @@ def test_map_rectangle_drag_is_wired_to_automatic_analysis() -> None:
|
||||
|
||||
assert "onMapBboxPreview={handleMapBboxPreview}" in workspace
|
||||
assert "onMapBboxSelect={handleMapBboxSelect}" in workspace
|
||||
assert "void analyzeSelection(bbox, selectedMapArea?.id)" in workspace
|
||||
assert "void analyzeSelection(bbox)" in workspace
|
||||
assert "const areaIdForSelection" in workspace
|
||||
assert "&& bboxesEqual(bbox, selectedAreaBbox)" in workspace
|
||||
assert "void analyzeSelection(selectedAreaBbox, selectedMapArea?.id)" in workspace
|
||||
assert "map.on('mousedown'" in geomap
|
||||
assert "map.on('mousemove'" in geomap
|
||||
assert "map.on('mouseup'" in geomap
|
||||
|
||||
@@ -33,8 +33,10 @@ def test_regional_map_uses_logical_partition_groups_and_exact_analysis() -> None
|
||||
assert "/datasets/raster/terrain/select" in api
|
||||
assert "/datasets/raster/flood-hazard/select" in api
|
||||
assert "Rasterlaag actief" in app
|
||||
assert "void analyzeSelection(bbox, selectedMapArea?.id)" in workspace
|
||||
assert "onDeriveMapSelectionDataset(bbox, selectedMapArea?.id)" in workspace
|
||||
assert "void analyzeSelection(bbox)" in workspace
|
||||
assert "areaIdForSelection(bbox)" in workspace
|
||||
assert "void analyzeSelection(selectedAreaBbox, selectedMapArea?.id)" in workspace
|
||||
assert "onDeriveMapSelectionDataset(bbox, areaIdForSelection(bbox))" in workspace
|
||||
|
||||
|
||||
def test_maplibre_supports_multiple_persisted_raster_overlays() -> None:
|
||||
|
||||
@@ -36,9 +36,12 @@ def test_completed_analysis_hands_off_to_ai_and_downloads_responsively() -> None
|
||||
assert "onOpenAssistant: () => void" in workspace
|
||||
assert "onOpenExports: () => void" in workspace
|
||||
assert "Stel AI-vraag" in workspace
|
||||
assert "Open downloads" in workspace
|
||||
assert "Bewaar in downloads" in workspace
|
||||
assert "persistActiveResultAndOpenDownloads" in workspace
|
||||
assert "onPersistMapResult(payload)" in workspace
|
||||
assert "onOpenAssistant={() => setActiveWorkspace('assistant')}" in app
|
||||
assert "onOpenExports={() => setActiveWorkspace('exports')}" in app
|
||||
assert "onPersistMapResult={persistMapResult}" in app
|
||||
assert 'className="workspace-persistent-map"' in app
|
||||
assert "hidden={activeWorkspace !== 'map'}" in app
|
||||
assert "{activeWorkspace === 'map' ? (" not in app
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.main import app
|
||||
from app.models import Dataset, Export
|
||||
from app.schemas.export import ExportCreateResponse, MapResultExportRequest
|
||||
from app.schemas.project import ProjectRead
|
||||
from app.services.export_service import ExportService
|
||||
from app.services.project_service import ProjectService
|
||||
from app.services.storage_service import StorageService
|
||||
from app.services.temporal_analysis_service import TemporalAnalysisService
|
||||
from app.services.thematic_raster_analysis_service import ThematicRasterAnalysisService
|
||||
|
||||
|
||||
class FakeSession:
|
||||
def __init__(self, rows=None):
|
||||
self.rows = rows or {}
|
||||
self.added = []
|
||||
|
||||
def get(self, model, row_id):
|
||||
return self.rows.get((model, row_id))
|
||||
|
||||
def add(self, row):
|
||||
self.added.append(row)
|
||||
|
||||
def commit(self):
|
||||
return None
|
||||
|
||||
def refresh(self, row):
|
||||
return row
|
||||
|
||||
|
||||
def bbox_payload() -> dict:
|
||||
return {
|
||||
"min_x": 5.10,
|
||||
"min_y": 51.17,
|
||||
"max_x": 5.11,
|
||||
"max_y": 51.18,
|
||||
"crs": "EPSG:4326",
|
||||
}
|
||||
|
||||
|
||||
def test_map_result_export_request_requires_a_complete_target() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
MapResultExportRequest(project_id=uuid4(), mode="current", bbox=bbox_payload())
|
||||
with pytest.raises(ValidationError):
|
||||
MapResultExportRequest(project_id=uuid4(), mode="evolution", bbox=bbox_payload())
|
||||
with pytest.raises(ValidationError):
|
||||
MapResultExportRequest(
|
||||
project_id=uuid4(),
|
||||
mode="current",
|
||||
dataset_id=uuid4(),
|
||||
bbox=bbox_payload(),
|
||||
partitioned=True,
|
||||
)
|
||||
|
||||
|
||||
def test_current_vector_map_result_uses_authoritative_selection_export(monkeypatch) -> None:
|
||||
project_id = uuid4()
|
||||
dataset_id = uuid4()
|
||||
area_id = uuid4()
|
||||
dataset = Dataset(
|
||||
id=dataset_id,
|
||||
project_id=project_id,
|
||||
name="buildings.geojson",
|
||||
dataset_type="vector",
|
||||
source="fixture",
|
||||
status="ready",
|
||||
)
|
||||
db = FakeSession({(Dataset, dataset_id): dataset})
|
||||
expected = ExportCreateResponse(
|
||||
export_id=uuid4(),
|
||||
path="storage/exports/buildings-selection.geojson",
|
||||
status="ready",
|
||||
export_type="vector_selection_geojson",
|
||||
)
|
||||
captured: dict = {}
|
||||
|
||||
def fake_vector_export(*_args, **kwargs):
|
||||
captured.update(kwargs)
|
||||
return expected
|
||||
|
||||
monkeypatch.setattr(ExportService, "export_vector_selection_geojson", fake_vector_export)
|
||||
response = ExportService.export_map_result(
|
||||
db,
|
||||
MapResultExportRequest(
|
||||
project_id=project_id,
|
||||
mode="current",
|
||||
dataset_id=dataset_id,
|
||||
area_id=area_id,
|
||||
bbox=bbox_payload(),
|
||||
theme_id="buildings",
|
||||
),
|
||||
)
|
||||
|
||||
assert response is expected
|
||||
assert captured["area_id"] == area_id
|
||||
assert captured["limit"] == 1000
|
||||
|
||||
|
||||
def test_raster_map_result_is_recomputed_and_persisted(tmp_path, monkeypatch) -> None:
|
||||
project_id = uuid4()
|
||||
dataset_id = uuid4()
|
||||
dataset = Dataset(
|
||||
id=dataset_id,
|
||||
project_id=project_id,
|
||||
name="space-occupation.tif",
|
||||
dataset_type="raster",
|
||||
source="official",
|
||||
source_name="department_omgeving_thematic_raster",
|
||||
status="ready",
|
||||
)
|
||||
db = FakeSession({(Dataset, dataset_id): dataset})
|
||||
export_path = tmp_path / "space-occupation-analysis.json"
|
||||
captured: dict = {}
|
||||
|
||||
def fake_analyze(_db, captured_project_id, captured_dataset_id, payload):
|
||||
captured.update(
|
||||
project_id=captured_project_id,
|
||||
dataset_id=captured_dataset_id,
|
||||
area_id=payload.area_id,
|
||||
)
|
||||
return {
|
||||
"selection_bbox": bbox_payload(),
|
||||
"summary": {"metric_label": "Ruimtebeslag", "metric_value": 12.5, "metric_unit": "ha"},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(ThematicRasterAnalysisService, "analyze", fake_analyze)
|
||||
monkeypatch.setattr(StorageService, "dataset_export_path", lambda *_args: str(export_path))
|
||||
response = ExportService.export_map_result(
|
||||
db,
|
||||
MapResultExportRequest(
|
||||
project_id=project_id,
|
||||
mode="current",
|
||||
dataset_id=dataset_id,
|
||||
bbox=bbox_payload(),
|
||||
theme_id="space_occupation",
|
||||
),
|
||||
)
|
||||
|
||||
persisted = [row for row in db.added if isinstance(row, Export)]
|
||||
assert response.export_type == "map_analysis_json"
|
||||
assert len(persisted) == 1
|
||||
assert persisted[0].metadata_json["server_recomputed"] is True
|
||||
assert persisted[0].metadata_json["theme_id"] == "space_occupation"
|
||||
assert captured == {"project_id": project_id, "dataset_id": dataset_id, "area_id": None}
|
||||
assert json.loads(export_path.read_text(encoding="utf-8"))["result"]["summary"]["metric_value"] == 12.5
|
||||
|
||||
|
||||
def test_evolution_map_result_is_recomputed_and_persisted(tmp_path, monkeypatch) -> None:
|
||||
project_id = uuid4()
|
||||
earlier_id = uuid4()
|
||||
later_id = uuid4()
|
||||
db = FakeSession()
|
||||
export_path = tmp_path / "forest-evolution.json"
|
||||
captured: dict = {}
|
||||
|
||||
class Comparison:
|
||||
def model_dump(self, *, mode):
|
||||
assert mode == "json"
|
||||
return {"temporal_series_key": "forest", "metric": {"absolute_change": -2.0}}
|
||||
|
||||
def fake_compare(_db, *, project_id, payload):
|
||||
captured.update(project_id=project_id, payload=payload)
|
||||
return Comparison()
|
||||
|
||||
monkeypatch.setattr(TemporalAnalysisService, "compare", fake_compare)
|
||||
monkeypatch.setattr(StorageService, "dataset_export_path", lambda *_args: str(export_path))
|
||||
response = ExportService.export_map_result(
|
||||
db,
|
||||
MapResultExportRequest(
|
||||
project_id=project_id,
|
||||
mode="evolution",
|
||||
earlier_dataset_id=earlier_id,
|
||||
later_dataset_id=later_id,
|
||||
bbox=bbox_payload(),
|
||||
theme_id="forest",
|
||||
),
|
||||
)
|
||||
|
||||
assert response.export_type == "map_evolution_json"
|
||||
assert captured["project_id"] == project_id
|
||||
assert captured["payload"].earlier_dataset_id == earlier_id
|
||||
assert json.loads(export_path.read_text(encoding="utf-8"))["metric"]["absolute_change"] == -2.0
|
||||
|
||||
|
||||
def test_map_result_export_endpoint_uses_canonical_envelope(monkeypatch) -> None:
|
||||
project_id = uuid4()
|
||||
dataset_id = uuid4()
|
||||
export_id = uuid4()
|
||||
monkeypatch.setattr(
|
||||
ExportService,
|
||||
"export_map_result",
|
||||
lambda *_args: ExportCreateResponse(
|
||||
export_id=export_id,
|
||||
path="storage/exports/map-analysis.json",
|
||||
status="ready",
|
||||
export_type="map_analysis_json",
|
||||
),
|
||||
)
|
||||
|
||||
response = TestClient(app).post(
|
||||
"/api/v1/exports/map-result",
|
||||
json={
|
||||
"project_id": str(project_id),
|
||||
"mode": "current",
|
||||
"dataset_id": str(dataset_id),
|
||||
"bbox": bbox_payload(),
|
||||
"theme_id": "space_occupation",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"data": {
|
||||
"export_id": str(export_id),
|
||||
"path": "storage/exports/map-analysis.json",
|
||||
"status": "ready",
|
||||
"export_type": "map_analysis_json",
|
||||
"metadata_json": None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def test_frontend_persists_map_result_before_opening_downloads() -> None:
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
workspace = (root / "frontend/src/components/map/MapWorkspace.tsx").read_text(encoding="utf-8")
|
||||
hook = (root / "frontend/src/hooks/useExportWorkflow.ts").read_text(encoding="utf-8")
|
||||
api = (root / "frontend/src/services/api/exports.ts").read_text(encoding="utf-8")
|
||||
|
||||
assert "persistActiveResultAndOpenDownloads" in workspace
|
||||
assert "onPersistMapResult(payload)" in workspace
|
||||
assert "Bewaar in downloads" in workspace
|
||||
assert "persistMapResult" in hook
|
||||
assert "exportsApi.exportMapResult(payload)" in hook
|
||||
assert "/api/v1/exports/map-result" in api
|
||||
|
||||
|
||||
def test_project_list_supports_exact_canonical_workspace_lookup(monkeypatch) -> None:
|
||||
project_id = uuid4()
|
||||
captured: dict = {}
|
||||
|
||||
def fake_list(_db, *, limit, offset, name):
|
||||
captured.update(limit=limit, offset=offset, name=name)
|
||||
return [
|
||||
ProjectRead(
|
||||
id=project_id,
|
||||
name="Kempen Regional Workbench",
|
||||
region="Kempen",
|
||||
status="active",
|
||||
)
|
||||
], 1
|
||||
|
||||
monkeypatch.setattr(ProjectService, "list_projects", fake_list)
|
||||
response = TestClient(app).get(
|
||||
"/api/v1/projects",
|
||||
params={"name": "Kempen Regional Workbench", "limit": 1},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["data"]["items"][0]["id"] == str(project_id)
|
||||
assert captured == {
|
||||
"limit": 1,
|
||||
"offset": 0,
|
||||
"name": "Kempen Regional Workbench",
|
||||
}
|
||||
|
||||
|
||||
def test_frontend_fetches_canonical_workspace_outside_default_project_page() -> None:
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
workflow = (root / "frontend/src/hooks/useProjectWorkspace.ts").read_text(encoding="utf-8")
|
||||
api = (root / "frontend/src/services/api/projects.ts").read_text(encoding="utf-8")
|
||||
|
||||
assert "projectsApi.list({ name: REGIONAL_WORKSPACE_PROJECT_NAME, limit: 1 })" in workflow
|
||||
assert "[...canonicalResponse.items, ...response.items]" in workflow
|
||||
assert "new URLSearchParams()" in api
|
||||
|
||||
|
||||
def test_theme_failures_name_the_source_and_reason() -> None:
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
hook = (root / "frontend/src/hooks/useMapThemeSelectionInsights.ts").read_text(encoding="utf-8")
|
||||
|
||||
assert "dataset: queries[index]?.dataset.name" in hook
|
||||
assert "reason: formatError(item.reason" in hook
|
||||
assert "failure.dataset}: ${failure.reason}" in hook
|
||||
|
||||
|
||||
def test_workspace_navigation_resets_the_actual_scroll_container() -> None:
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
app = (root / "frontend/src/App.tsx").read_text(encoding="utf-8")
|
||||
|
||||
assert "const workbenchMainRef = useRef<HTMLElement | null>(null)" in app
|
||||
assert "workbenchMainRef.current?.scrollTo({ top: 0, left: 0 })" in app
|
||||
assert "<main ref={workbenchMainRef}" in app
|
||||
|
||||
|
||||
def test_quality_scores_have_plain_language_interpretation() -> None:
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
quality = (root / "frontend/src/components/quality/QualityResultsPanel.tsx").read_text(encoding="utf-8")
|
||||
detection = (root / "frontend/src/components/detection/DetectionLab.tsx").read_text(encoding="utf-8")
|
||||
|
||||
assert "Laatste score (0-1)" in quality
|
||||
assert "Bruikbaar na controle" in quality
|
||||
assert "Verkennend, controle vereist" in quality
|
||||
assert "detectionQualityInterpretation" in detection
|
||||
assert "Verkennend resultaat; beoordeel fouten" in detection
|
||||
|
||||
|
||||
def test_map_and_detection_workspaces_avoid_page_length_driven_layouts() -> None:
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
styles = (root / "frontend/src/styles/app.css").read_text(encoding="utf-8")
|
||||
premium = (root / "frontend/src/styles/premium.css").read_text(encoding="utf-8")
|
||||
|
||||
assert "height: clamp(34rem, calc(100dvh - 10rem), 58rem);" in styles
|
||||
assert ".geo-theme-list" in styles and "overflow-y: auto;" in styles
|
||||
assert "@media (max-width: 1240px)" in styles
|
||||
assert ".workspace-grid-ai {\n grid-template-columns: minmax(0, 1fr);" in premium
|
||||
assert "max-height: none;" in premium
|
||||
|
||||
|
||||
def test_download_workspace_surfaces_map_results_in_plain_dutch() -> None:
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
exports = (root / "frontend/src/components/exports/ExportCenter.tsx").read_text(encoding="utf-8")
|
||||
|
||||
assert "Gebiedsanalyse (JSON)" in exports
|
||||
assert "Historische vergelijking (JSON)" in exports
|
||||
assert "Kaartselectie (GeoJSON)" in exports
|
||||
assert "Klaar om te delen" in exports
|
||||
assert "Downloads vernieuwen" in exports
|
||||
assert "JSON bekijken" in exports
|
||||
@@ -35,10 +35,10 @@ def test_app_entrypoint_has_clean_encoding_and_react_imports() -> None:
|
||||
app = app_path.read_text(encoding="utf-8")
|
||||
|
||||
assert not app_bytes.startswith(b"\xef\xbb\xbf")
|
||||
assert "import { useEffect, useMemo, useState } from 'react'" in app
|
||||
assert "import { useEffect, useMemo, useRef, useState } from 'react'" in app
|
||||
assert "FormEvent" not in app
|
||||
assert app.count("useEffect(") == 1
|
||||
assert "window.scrollTo({ top: 0, left: 0 })" in app
|
||||
assert "workbenchMainRef.current?.scrollTo({ top: 0, left: 0 })" in app
|
||||
assert "const [activeWorkspace, setActiveWorkspace] = useState<WorkspaceKey>('map')" in app
|
||||
|
||||
|
||||
|
||||
@@ -38,12 +38,12 @@ def test_export_center_uses_artifact_actions_and_cards() -> None:
|
||||
assert "exportMatchesSearch" in export_center
|
||||
assert "filteredExports = useMemo" in export_center
|
||||
assert "visibleExports = showAllExports ? filteredExports : filteredExports.slice(0, 10)" in export_center
|
||||
assert "No exports match the current filters." in export_center
|
||||
assert "Reset view" in export_center
|
||||
assert "Show all exports" in export_center
|
||||
assert "Geen downloads passen bij deze filters." in export_center
|
||||
assert "Filters wissen" in export_center
|
||||
assert "Toon alle downloads" in export_center
|
||||
assert "onExportDataset" in export_center
|
||||
assert "onExportDetectionRun" in export_center
|
||||
assert "onExportSegmentationRun" in export_center
|
||||
assert "download-only HTML artifact" in export_center
|
||||
assert "HTML alleen downloaden" in export_center
|
||||
assert 'className="export-preview-panel"' in export_preview
|
||||
assert "Nog geen bestand gekozen." in export_preview
|
||||
|
||||
@@ -35,7 +35,7 @@ def test_shell_preserves_workspace_width_on_standard_desktop_viewports() -> None
|
||||
assert "@media (max-width: 1360px)" in css
|
||||
assert ".workbench-inspector {\n grid-column: 1 / -1;" in css
|
||||
assert "height: auto;" in css
|
||||
assert "window.scrollTo({ top: 0, left: 0 })" in app
|
||||
assert "workbenchMainRef.current?.scrollTo({ top: 0, left: 0 })" in app
|
||||
assert "}, [activeWorkspace])" in app
|
||||
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ def test_quality_results_panel_exposes_selected_check_drilldown() -> None:
|
||||
assert "Te controleren laag" in panel
|
||||
assert "Referentielaag" in panel
|
||||
assert "Analyserun" in panel
|
||||
assert "Job" in panel
|
||||
assert "Taak" in panel
|
||||
assert "Afgerond" in panel
|
||||
assert "Laatste controle bekijken" in panel
|
||||
assert "Controle bekijken" in panel
|
||||
|
||||
Reference in New Issue
Block a user