Persist map selections as derived datasets
GeoIntel CI / docs-smoke (push) Has been cancelled
GeoIntel CI / contract-smoke (push) Has been cancelled

This commit is contained in:
Codex
2026-06-25 02:25:49 +02:00
parent 1e42302f62
commit b92faafa74
16 changed files with 587 additions and 2 deletions
+23
View File
@@ -25,6 +25,7 @@ from app.schemas import (
VectorClipRequest,
VectorIntersectRequest,
VectorSelectionBBox,
VectorSelectionDeriveRequest,
VectorSelectionRequest,
VectorSelectionResponse,
)
@@ -205,6 +206,28 @@ def select_vector_features(
return envelope(VectorSelectionResponse(**result).model_dump())
@router.post("/datasets/{dataset_id}/vector/select/derive", status_code=201, response_model=dict)
def derive_vector_selection_dataset(
project_id: UUID,
dataset_id: UUID,
payload: VectorSelectionDeriveRequest,
db: Session = Depends(get_db),
):
dataset = DatasetService.get_dataset(db, dataset_id)
if dataset.project_id != project_id:
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)
derived = VectorOperationsService.derive_selection_dataset(
db=db,
dataset_id=dataset_id,
bbox=payload.bbox.model_dump(),
limit=payload.limit,
output_name=payload.output_name,
)
return envelope(derived.model_dump())
@router.post("/datasets/{dataset_id}/vector/clip", status_code=201, response_model=dict)
def clip_vector_dataset(
project_id: UUID,
+2
View File
@@ -72,6 +72,7 @@ from .operations import (
VectorOperationRequest,
VectorOperationResult,
VectorSelectionBBox,
VectorSelectionDeriveRequest,
VectorSelectionRequest,
VectorSelectionResponse,
VectorStatsRequest,
@@ -126,6 +127,7 @@ __all__ = [
"VectorOperationRequest",
"VectorOperationResult",
"VectorSelectionBBox",
"VectorSelectionDeriveRequest",
"VectorSelectionRequest",
"VectorSelectionResponse",
"RasterClipRequest",
+4
View File
@@ -213,6 +213,10 @@ class VectorSelectionRequest(BaseModel):
limit: int = Field(default=100, ge=1, le=1000)
class VectorSelectionDeriveRequest(VectorSelectionRequest):
output_name: str | None = None
class VectorSelectionResponse(BaseModel):
selection_bbox: VectorSelectionBBox
feature_count: int
@@ -2,6 +2,7 @@ from __future__ import annotations
import json
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
@@ -15,9 +16,11 @@ from sqlalchemy.orm import Session
from app.core.errors import AppError
from app.models import Area, Dataset
from app.schemas.dataset import DatasetCreateResponse
from app.schemas.operations import VectorOperationResult
from app.services.geojson_service import parse_geojson_payload
from app.services.storage_service import StorageService
from app.services.vector_feature_service import VectorFeatureService
class VectorOperationsService:
@@ -276,6 +279,125 @@ class VectorOperationsService:
default_name="vector_intersect",
)
@staticmethod
def derive_selection_dataset(
db: Session,
dataset_id: uuid.UUID,
bbox: dict[str, Any],
limit: int = 250,
output_name: str | None = None,
) -> DatasetCreateResponse:
source_dataset = db.get(Dataset, dataset_id)
if not source_dataset:
raise AppError(code="DATASET_NOT_FOUND", message="Dataset not found", status_code=404)
VectorOperationsService._require_vector_dataset(source_dataset)
selection = VectorFeatureService.select_features_by_bbox(db, dataset_id=dataset_id, bbox=bbox, limit=limit)
if selection["feature_count"] <= 0:
raise AppError(
code="VECTOR_OPERATION_EMPTY_RESULT",
message="Selection produced no output features",
status_code=422,
)
feature_collection = VectorOperationsService._selection_geojson_for_derived_dataset(
selection["geojson"],
source_dataset_id=dataset_id,
)
derived_id = VectorOperationsService._persist_derived_dataset(
db=db,
source_dataset=source_dataset,
source_id=dataset_id,
operation="selection",
feature_collection=feature_collection,
output_name=output_name,
default_name="map_selection",
dataset_role="derived",
source_name="map_selection",
source_metadata={
"selection_bbox": selection["selection_bbox"],
"feature_count": selection["feature_count"],
"limit": selection["limit"],
"truncated": selection["truncated"],
"source_table": "vector_features",
},
provenance_metadata={
"operation": "map_bbox_selection",
"source_dataset_id": str(dataset_id),
"source_table": "vector_features",
"selection_bbox": selection["selection_bbox"],
},
metadata_extra={
"selection_bbox": selection["selection_bbox"],
"source_feature_count": selection["feature_count"],
"selection_limit": selection["limit"],
"selection_truncated": selection["truncated"],
"source_dataset_id": str(dataset_id),
"source_table": "vector_features",
},
persist_vector_features=True,
)
derived = db.get(Dataset, derived_id)
if not derived:
raise AppError(code="DATASET_NOT_FOUND", message="Derived dataset was not persisted", status_code=500)
metadata = derived.metadata_json or {}
return DatasetCreateResponse(
id=derived.id,
name=derived.name,
dataset_type=derived.dataset_type,
source=derived.source,
dataset_role=derived.dataset_role,
source_name=derived.source_name,
reference_layer_name=derived.reference_layer_name,
source_metadata=derived.source_metadata,
provenance_metadata=derived.provenance_metadata,
imported_at=derived.imported_at,
project_id=derived.project_id,
area_id=derived.area_id,
storage_path=derived.storage_path,
original_filename=derived.original_filename,
stored_filename=derived.stored_filename,
content_type=derived.content_type,
size_bytes=derived.size_bytes,
checksum_sha256=derived.checksum_sha256,
crs=derived.crs,
bounds_json=derived.bounds_json,
resolution_json=derived.resolution_json,
bands_json=derived.bands_json,
metadata_json=derived.metadata_json,
vector_summary=None,
status=derived.status,
derived_from_dataset_id=derived.derived_from_dataset_id,
created_at=derived.created_at,
feature_count=metadata.get("feature_count") if isinstance(metadata, dict) else None,
)
@staticmethod
def _selection_geojson_for_derived_dataset(payload: dict[str, Any], source_dataset_id: uuid.UUID) -> dict[str, Any]:
features = payload.get("features")
if payload.get("type") != "FeatureCollection" or not isinstance(features, list):
raise AppError(code="INVALID_GEOJSON", message="Selection payload must be a FeatureCollection", status_code=500)
output_features: list[dict[str, Any]] = []
for feature in features:
if not isinstance(feature, dict):
continue
properties = dict(feature.get("properties") or {})
source_vector_feature_id = properties.pop("vector_feature_id", feature.get("id"))
properties.pop("dataset_id", None)
properties["source_dataset_id"] = str(source_dataset_id)
if source_vector_feature_id is not None:
properties["source_vector_feature_id"] = str(source_vector_feature_id)
output_features.append(
{
"type": "Feature",
"geometry": feature.get("geometry"),
"properties": properties,
}
)
return {"type": "FeatureCollection", "features": output_features}
@staticmethod
def _persist_derived_dataset(
db: Session,
@@ -285,6 +407,12 @@ class VectorOperationsService:
feature_collection: dict[str, Any],
output_name: str | None,
default_name: str,
dataset_role: str = "derived",
source_name: str | None = None,
source_metadata: dict[str, Any] | None = None,
provenance_metadata: dict[str, Any] | None = None,
metadata_extra: dict[str, Any] | None = None,
persist_vector_features: bool = False,
) -> uuid.UUID:
derived_id = uuid.uuid4()
output_name_value = f"{(output_name or default_name)}.geojson"
@@ -302,6 +430,8 @@ class VectorOperationsService:
)
metadata = parse_geojson_payload(json.dumps(feature_collection, ensure_ascii=False, separators=(",", ":")))
if metadata_extra:
metadata.update(metadata_extra)
derived_dataset = Dataset(
id=derived_id,
project_id=source_dataset.project_id,
@@ -309,6 +439,11 @@ class VectorOperationsService:
name=output_name_value,
dataset_type="vector",
source=f"operation:{operation}",
dataset_role=dataset_role,
source_name=source_name,
source_metadata=source_metadata,
provenance_metadata=provenance_metadata,
imported_at=datetime.now(timezone.utc),
storage_path=storage_info["storage_path"],
original_filename=storage_info["original_filename"],
stored_filename=storage_info["stored_filename"],
@@ -326,4 +461,10 @@ class VectorOperationsService:
db.add(derived_dataset)
db.commit()
db.refresh(derived_dataset)
if persist_vector_features:
VectorFeatureService.persist_geojson_features(
db=db,
dataset_id=derived_dataset.id,
payload=feature_collection,
)
return derived_id