fix: query persisted work area geometry
This commit is contained in:
@@ -237,19 +237,32 @@ def select_vector_features(
|
||||
raise HTTPException(status_code=404, detail="Dataset not found")
|
||||
if dataset.dataset_type not in {"vector", "geojson"}:
|
||||
raise AppError(code="DATASET_NOT_VECTOR", message="Area selection requires a vector dataset", status_code=400)
|
||||
result = VectorFeatureService.select_features_by_bbox(
|
||||
db,
|
||||
dataset_id=dataset_id,
|
||||
bbox=payload.bbox.model_dump(),
|
||||
limit=payload.limit,
|
||||
)
|
||||
if isinstance(dataset.source_metadata, dict) and dataset.source_metadata.get("selection_aggregation"):
|
||||
result["summary"] = VectorFeatureService.summarize_features_by_bbox(
|
||||
db,
|
||||
dataset=dataset,
|
||||
bbox=payload.bbox.model_dump(),
|
||||
total_feature_count=result.get("total_feature_count"),
|
||||
selection_area = None
|
||||
if payload.area_id is not None:
|
||||
selection_area = db.get(Area, payload.area_id)
|
||||
if selection_area is None or selection_area.project_id != project_id:
|
||||
raise AppError(code="AREA_NOT_FOUND", message="Area not found", status_code=404)
|
||||
|
||||
selection_kwargs = {
|
||||
"dataset_id": dataset_id,
|
||||
"bbox": payload.bbox.model_dump(),
|
||||
"limit": payload.limit,
|
||||
}
|
||||
if selection_area is not None:
|
||||
selection_kwargs.update(
|
||||
selection_geometry=selection_area.geometry,
|
||||
selection_area_id=selection_area.id,
|
||||
)
|
||||
result = VectorFeatureService.select_features_by_bbox(db, **selection_kwargs)
|
||||
if isinstance(dataset.source_metadata, dict) and dataset.source_metadata.get("selection_aggregation"):
|
||||
summary_kwargs = {
|
||||
"dataset": dataset,
|
||||
"bbox": payload.bbox.model_dump(),
|
||||
"total_feature_count": result.get("total_feature_count"),
|
||||
}
|
||||
if selection_area is not None:
|
||||
summary_kwargs["selection_geometry"] = selection_area.geometry
|
||||
result["summary"] = VectorFeatureService.summarize_features_by_bbox(db, **summary_kwargs)
|
||||
return envelope(VectorSelectionResponse(**result).model_dump(exclude_none=True))
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
@@ -210,6 +212,7 @@ class VectorSelectionBBox(BaseModel):
|
||||
|
||||
class VectorSelectionRequest(BaseModel):
|
||||
bbox: VectorSelectionBBox
|
||||
area_id: UUID | None = None
|
||||
limit: int = Field(default=100, ge=1, le=1000)
|
||||
|
||||
|
||||
@@ -229,6 +232,7 @@ class VectorSelectionSummary(BaseModel):
|
||||
|
||||
class VectorSelectionResponse(BaseModel):
|
||||
selection_bbox: VectorSelectionBBox
|
||||
selection_area_id: UUID | None = None
|
||||
feature_count: int
|
||||
total_feature_count: int | None = None
|
||||
limit: int
|
||||
|
||||
@@ -124,25 +124,25 @@ class VectorFeatureService:
|
||||
bbox: dict[str, Any],
|
||||
limit: int = 100,
|
||||
dataset: Dataset | None = None,
|
||||
selection_geometry: Any | None = None,
|
||||
selection_area_id: UUID | None = None,
|
||||
) -> dict[str, Any]:
|
||||
normalized_bbox = VectorFeatureService._normalize_selection_bbox(bbox)
|
||||
safe_limit = max(1, min(int(limit), 1000))
|
||||
selection_shape = selection_geometry
|
||||
if selection_shape is None:
|
||||
selection_shape = ST_MakeEnvelope(
|
||||
normalized_bbox["min_x"],
|
||||
normalized_bbox["min_y"],
|
||||
normalized_bbox["max_x"],
|
||||
normalized_bbox["max_y"],
|
||||
4326,
|
||||
)
|
||||
|
||||
query = (
|
||||
db.query(VectorFeature)
|
||||
.filter(VectorFeature.dataset_id == dataset_id)
|
||||
.filter(
|
||||
ST_Intersects(
|
||||
VectorFeature.geometry,
|
||||
ST_MakeEnvelope(
|
||||
normalized_bbox["min_x"],
|
||||
normalized_bbox["min_y"],
|
||||
normalized_bbox["max_x"],
|
||||
normalized_bbox["max_y"],
|
||||
4326,
|
||||
),
|
||||
)
|
||||
)
|
||||
.filter(ST_Intersects(VectorFeature.geometry, selection_shape))
|
||||
)
|
||||
if hasattr(query, "count"):
|
||||
total_feature_count = int(query.count())
|
||||
@@ -164,9 +164,10 @@ class VectorFeatureService:
|
||||
dataset=dataset,
|
||||
bbox=normalized_bbox,
|
||||
total_feature_count=total_feature_count,
|
||||
selection_geometry=selection_geometry,
|
||||
)
|
||||
|
||||
return {
|
||||
result = {
|
||||
"selection_bbox": normalized_bbox,
|
||||
"feature_count": len(features),
|
||||
"total_feature_count": total_feature_count,
|
||||
@@ -178,6 +179,9 @@ class VectorFeatureService:
|
||||
},
|
||||
"summary": summary,
|
||||
}
|
||||
if selection_area_id is not None:
|
||||
result["selection_area_id"] = str(selection_area_id)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def summarize_features_by_bbox(
|
||||
@@ -186,18 +190,21 @@ class VectorFeatureService:
|
||||
dataset: Dataset,
|
||||
bbox: dict[str, Any],
|
||||
total_feature_count: int | None = None,
|
||||
selection_geometry: Any | None = None,
|
||||
) -> dict[str, Any]:
|
||||
normalized_bbox = VectorFeatureService._normalize_selection_bbox(bbox)
|
||||
envelope = ST_MakeEnvelope(
|
||||
normalized_bbox["min_x"],
|
||||
normalized_bbox["min_y"],
|
||||
normalized_bbox["max_x"],
|
||||
normalized_bbox["max_y"],
|
||||
4326,
|
||||
)
|
||||
selection_shape = selection_geometry
|
||||
if selection_shape is None:
|
||||
selection_shape = ST_MakeEnvelope(
|
||||
normalized_bbox["min_x"],
|
||||
normalized_bbox["min_y"],
|
||||
normalized_bbox["max_x"],
|
||||
normalized_bbox["max_y"],
|
||||
4326,
|
||||
)
|
||||
selection_filter = (
|
||||
VectorFeature.dataset_id == dataset.id,
|
||||
ST_Intersects(VectorFeature.geometry, envelope),
|
||||
ST_Intersects(VectorFeature.geometry, selection_shape),
|
||||
)
|
||||
feature_count = total_feature_count
|
||||
if feature_count is None:
|
||||
@@ -215,13 +222,13 @@ class VectorFeatureService:
|
||||
|
||||
metric_value = float(feature_count)
|
||||
if method == "intersection_area":
|
||||
intersection = func.ST_Intersection(VectorFeature.geometry, envelope)
|
||||
intersection = func.ST_Intersection(VectorFeature.geometry, selection_shape)
|
||||
area_expression = func.ST_Area(func.ST_Transform(intersection, 31370))
|
||||
area_m2 = db.query(func.coalesce(func.sum(area_expression), 0.0)).filter(*selection_filter).scalar()
|
||||
divisor = 10_000.0 if unit == "ha" else 1.0
|
||||
metric_value = float(area_m2 or 0.0) / divisor
|
||||
elif method == "intersection_length":
|
||||
intersection = func.ST_Intersection(VectorFeature.geometry, envelope)
|
||||
intersection = func.ST_Intersection(VectorFeature.geometry, selection_shape)
|
||||
length_expression = func.ST_Length(func.ST_Transform(intersection, 31370))
|
||||
length_m = db.query(func.coalesce(func.sum(length_expression), 0.0)).filter(*selection_filter).scalar()
|
||||
divisor = 1_000.0 if unit == "km" else 1.0
|
||||
@@ -240,7 +247,7 @@ class VectorFeatureService:
|
||||
if method == "area_weighted_sum":
|
||||
source_area = func.ST_Area(func.ST_Transform(VectorFeature.geometry, 31370))
|
||||
intersection_area = func.ST_Area(
|
||||
func.ST_Transform(func.ST_Intersection(VectorFeature.geometry, envelope), 31370)
|
||||
func.ST_Transform(func.ST_Intersection(VectorFeature.geometry, selection_shape), 31370)
|
||||
)
|
||||
coverage_ratio = intersection_area / func.nullif(source_area, 0.0)
|
||||
value_expression = numeric_value * coverage_ratio
|
||||
|
||||
@@ -162,11 +162,108 @@ def test_vector_select_route_rejects_non_vector_dataset(monkeypatch) -> None:
|
||||
raise AssertionError("Expected DATASET_NOT_VECTOR")
|
||||
|
||||
|
||||
def test_vector_select_route_uses_persisted_area_geometry_when_requested(monkeypatch) -> None:
|
||||
from app.api.routes import datasets as dataset_routes
|
||||
|
||||
project_id = uuid.uuid4()
|
||||
dataset_id = uuid.uuid4()
|
||||
area_id = uuid.uuid4()
|
||||
dataset = Dataset(
|
||||
id=dataset_id,
|
||||
project_id=project_id,
|
||||
dataset_type="vector",
|
||||
source="fixture",
|
||||
name="Regional vector",
|
||||
source_metadata={"selection_aggregation": {"method": "feature_count"}},
|
||||
)
|
||||
area_geometry = object()
|
||||
area = SimpleNamespace(id=area_id, project_id=project_id, geometry=area_geometry)
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
class _AreaSession:
|
||||
@staticmethod
|
||||
def get(model, selected_id): # noqa: ANN001
|
||||
assert model is dataset_routes.Area
|
||||
assert selected_id == area_id
|
||||
return area
|
||||
|
||||
def select_features(db, **kwargs): # noqa: ANN001
|
||||
captured["select"] = kwargs
|
||||
return {
|
||||
"selection_bbox": {"min_x": 4.9, "min_y": 50.9, "max_x": 5.2, "max_y": 51.2, "crs": "EPSG:4326"},
|
||||
"selection_area_id": str(area_id),
|
||||
"feature_count": 1,
|
||||
"total_feature_count": 1,
|
||||
"limit": 100,
|
||||
"truncated": False,
|
||||
"geojson": {"type": "FeatureCollection", "features": []},
|
||||
}
|
||||
|
||||
def summarize_features(db, **kwargs): # noqa: ANN001
|
||||
captured["summary"] = kwargs
|
||||
return {
|
||||
"metric_label": "Gebouwen",
|
||||
"metric_value": 1,
|
||||
"metric_unit": "objecten",
|
||||
"aggregation_method": "feature_count",
|
||||
"feature_count": 1,
|
||||
"is_estimate": False,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(dataset_routes.DatasetService, "get_dataset", lambda db, selected_id: dataset)
|
||||
monkeypatch.setattr(dataset_routes.VectorFeatureService, "select_features_by_bbox", select_features)
|
||||
monkeypatch.setattr(dataset_routes.VectorFeatureService, "summarize_features_by_bbox", summarize_features)
|
||||
|
||||
response = dataset_routes.select_vector_features(
|
||||
project_id=project_id,
|
||||
dataset_id=dataset_id,
|
||||
payload=dataset_routes.VectorSelectionRequest(
|
||||
bbox=dataset_routes.VectorSelectionBBox(min_x=4.9, min_y=50.9, max_x=5.2, max_y=51.2),
|
||||
area_id=area_id,
|
||||
limit=100,
|
||||
),
|
||||
db=_AreaSession(),
|
||||
)
|
||||
|
||||
assert str(response["data"]["selection_area_id"]) == str(area_id)
|
||||
assert captured["select"]["selection_geometry"] is area_geometry
|
||||
assert captured["select"]["selection_area_id"] == area_id
|
||||
assert captured["summary"]["selection_geometry"] is area_geometry
|
||||
|
||||
|
||||
def test_vector_select_route_rejects_area_from_another_project(monkeypatch) -> None:
|
||||
from app.api.routes import datasets as dataset_routes
|
||||
|
||||
project_id = uuid.uuid4()
|
||||
dataset_id = uuid.uuid4()
|
||||
area_id = uuid.uuid4()
|
||||
dataset = Dataset(id=dataset_id, project_id=project_id, dataset_type="vector", source="fixture", name="Vector")
|
||||
other_area = SimpleNamespace(id=area_id, project_id=uuid.uuid4(), geometry=object())
|
||||
monkeypatch.setattr(dataset_routes.DatasetService, "get_dataset", lambda db, selected_id: dataset)
|
||||
|
||||
try:
|
||||
dataset_routes.select_vector_features(
|
||||
project_id=project_id,
|
||||
dataset_id=dataset_id,
|
||||
payload=dataset_routes.VectorSelectionRequest(
|
||||
bbox=dataset_routes.VectorSelectionBBox(min_x=4.9, min_y=50.9, max_x=5.2, max_y=51.2),
|
||||
area_id=area_id,
|
||||
),
|
||||
db=SimpleNamespace(get=lambda model, selected_id: other_area),
|
||||
)
|
||||
except AppError as exc:
|
||||
assert exc.code == "AREA_NOT_FOUND"
|
||||
else: # pragma: no cover
|
||||
raise AssertionError("Expected AREA_NOT_FOUND")
|
||||
|
||||
|
||||
def test_frontend_exposes_map_bbox_selection_contracts() -> None:
|
||||
api_client = (ROOT / "frontend" / "src" / "services" / "api" / "datasets.ts").read_text(encoding="utf-8")
|
||||
map_workspace = (ROOT / "frontend" / "src" / "components" / "map" / "MapWorkspace.tsx").read_text(encoding="utf-8")
|
||||
geomap = (ROOT / "frontend" / "src" / "components" / "GeoMap.tsx").read_text(encoding="utf-8")
|
||||
app = (ROOT / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8")
|
||||
extract_hook = (ROOT / "frontend" / "src" / "hooks" / "useMapSelectionExtract.ts").read_text(encoding="utf-8")
|
||||
theme_hook = (ROOT / "frontend" / "src" / "hooks" / "useMapThemeSelectionInsights.ts").read_text(encoding="utf-8")
|
||||
|
||||
assert "selectVectorFeatures" in api_client
|
||||
assert "Area selection" in map_workspace
|
||||
@@ -177,3 +274,6 @@ def test_frontend_exposes_map_bbox_selection_contracts() -> None:
|
||||
assert "selection-bbox" in geomap
|
||||
assert "selection-result" in geomap
|
||||
assert "useMapSelectionExtract" in app
|
||||
assert "area_id: areaId" in extract_hook
|
||||
assert "area_id: areaId" in theme_hook
|
||||
assert "analyzeSelection(selectedAreaBbox, selectedMapArea?.id)" in map_workspace
|
||||
|
||||
Reference in New Issue
Block a user