GeoIntel release gates / Compile, test, contracts and builds (push) Successful in 1m49s
GeoIntel release gates / Python and npm vulnerability policy (push) Successful in 21s
GeoIntel release gates / Production AI image, SBOM and container scan (push) Successful in 5m39s
GeoIntel release gates / Deploy exact gated revision to Unraid (push) Failing after 58m43s
772 lines
33 KiB
Python
772 lines
33 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import UTC, datetime, timedelta
|
|
import hashlib
|
|
import json
|
|
import math
|
|
from pathlib import Path
|
|
from typing import Any, Callable
|
|
from urllib.error import HTTPError, URLError
|
|
from urllib.parse import parse_qsl, urlencode, urljoin, urlparse, urlunparse
|
|
from urllib.request import Request
|
|
from uuid import UUID
|
|
|
|
from geoalchemy2.shape import to_shape
|
|
from pyproj import Transformer
|
|
from shapely.geometry import LineString, MultiLineString, MultiPolygon, Polygon, box, mapping, shape
|
|
from shapely.ops import unary_union
|
|
from shapely.validation import make_valid
|
|
|
|
from app.core.config import Settings, get_settings
|
|
from app.core.errors import AppError
|
|
from app.services.outbound_request_guard import guarded_opener
|
|
from app.models import Area, Dataset, Project
|
|
from app.schemas.grb import GrbAcquireRequest, GrbAcquisitionResult, GrbProductRead
|
|
from app.services.dataset_service import DatasetService
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class GrbCollection:
|
|
name: str
|
|
geometry_dimension: int
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class GrbProduct:
|
|
key: str
|
|
display_name: str
|
|
reference_layer_name: str
|
|
layer_type: str
|
|
collections: tuple[GrbCollection, ...]
|
|
geometry_types: tuple[str, ...]
|
|
metric_key: str
|
|
metric_method: str
|
|
metric_label: str
|
|
metric_unit: str
|
|
metric_dimension: int
|
|
metric_warning: str
|
|
limitation_message: str
|
|
|
|
|
|
class GrbAcquisitionService:
|
|
PROVIDER = "grb"
|
|
SOURCE_CRS = "EPSG:4326"
|
|
OGC_CRS84_URI = "http://www.opengis.net/def/crs/OGC/1.3/CRS84"
|
|
AUTHORITY_LEVEL = "authoritative"
|
|
ATTRIBUTION = "Bron: Grootschalig Referentie Bestand Vlaanderen, Digitaal Vlaanderen"
|
|
LICENSE_NOTE = "Hergebruik volgens de open-datavoorwaarden en bronvermelding van Digitaal Vlaanderen."
|
|
CATALOG_URL = "https://www.vlaanderen.be/datavindplaats/catalogus/basiskaart-vlaanderen-grb"
|
|
|
|
@staticmethod
|
|
def _products() -> dict[str, GrbProduct]:
|
|
products = (
|
|
GrbProduct(
|
|
key="buildings",
|
|
display_name="GRB gebouwcontouren",
|
|
reference_layer_name="buildings",
|
|
layer_type="building",
|
|
collections=(GrbCollection("GBG", 2),),
|
|
geometry_types=("Polygon", "MultiPolygon"),
|
|
metric_key="footprint_area",
|
|
metric_method="intersection_area",
|
|
metric_label="Bebouwde grondoppervlakte",
|
|
metric_unit="ha",
|
|
metric_dimension=2,
|
|
metric_warning=(
|
|
"Dit is de grondoppervlakte van gebouwcontouren, niet de totale vloeroppervlakte "
|
|
"of het gebouwvolume."
|
|
),
|
|
limitation_message=(
|
|
"GRB GBG bevat gebouwcontouren uit de basiskaart. Registratie en fysieke verandering "
|
|
"kunnen in tijd verschillen."
|
|
),
|
|
),
|
|
GrbProduct(
|
|
key="roads",
|
|
display_name="GRB wegsegmenten",
|
|
reference_layer_name="roads",
|
|
layer_type="road",
|
|
collections=(GrbCollection("Wegsegment", 1),),
|
|
geometry_types=("LineString", "MultiLineString"),
|
|
metric_key="road_length",
|
|
metric_method="intersection_length",
|
|
metric_label="Totale weglengte",
|
|
metric_unit="km",
|
|
metric_dimension=1,
|
|
metric_warning=(
|
|
"De lengte volgt GRB-wegsegmenten en zegt niets over rijstroken, verkeersvolume "
|
|
"of verhardingsoppervlakte."
|
|
),
|
|
limitation_message=(
|
|
"GRB Wegsegment beschrijft netwerkgeometrie en is geen verkeersmodel of routeadvies."
|
|
),
|
|
),
|
|
GrbProduct(
|
|
key="water",
|
|
display_name="GRB wateroppervlakken en waterlijnen",
|
|
reference_layer_name="water",
|
|
layer_type="water",
|
|
collections=(
|
|
GrbCollection("WTZ", 2),
|
|
GrbCollection("WLAS", 1),
|
|
GrbCollection("WGR", 1),
|
|
),
|
|
geometry_types=("LineString", "MultiLineString", "Polygon", "MultiPolygon"),
|
|
metric_key="water_area",
|
|
metric_method="intersection_area",
|
|
metric_label="Wateroppervlakte",
|
|
metric_unit="ha",
|
|
metric_dimension=2,
|
|
metric_warning=(
|
|
"Watervolume is niet berekenbaar zonder betrouwbare diepte- of bathymetrische gegevens. "
|
|
"De GRB-bron levert alleen oppervlakte- en lijngeometrie."
|
|
),
|
|
limitation_message=(
|
|
"GRB-water combineert wateroppervlakken en watergerelateerde lijnen. Objectaantallen en "
|
|
"oppervlakte zijn geen actueel waterpeil of watervolume."
|
|
),
|
|
),
|
|
GrbProduct(
|
|
key="parcels",
|
|
display_name="GRB administratieve percelen",
|
|
reference_layer_name="parcels",
|
|
layer_type="parcel",
|
|
collections=(GrbCollection("ADP", 2),),
|
|
geometry_types=("Polygon", "MultiPolygon"),
|
|
metric_key="parcel_area",
|
|
metric_method="intersection_area",
|
|
metric_label="Perceeloppervlakte",
|
|
metric_unit="ha",
|
|
metric_dimension=2,
|
|
metric_warning=(
|
|
"GRB-percelen zijn een grafische referentie en vormen geen juridische grensopmeting."
|
|
),
|
|
limitation_message=(
|
|
"GRB ADP toont de vermoedelijke ligging van kadastrale percelen en is geen juridische grens."
|
|
),
|
|
),
|
|
)
|
|
return {product.key: product for product in products}
|
|
|
|
@staticmethod
|
|
def list_products() -> list[dict[str, Any]]:
|
|
return [
|
|
GrbProductRead(
|
|
key=product.key,
|
|
display_name=product.display_name,
|
|
reference_layer_name=product.reference_layer_name,
|
|
collections=[collection.name for collection in product.collections],
|
|
geometry_types=list(product.geometry_types),
|
|
source_crs=GrbAcquisitionService.SOURCE_CRS,
|
|
authority_level=GrbAcquisitionService.AUTHORITY_LEVEL,
|
|
catalog_url=GrbAcquisitionService.CATALOG_URL,
|
|
attribution=GrbAcquisitionService.ATTRIBUTION,
|
|
license_note=GrbAcquisitionService.LICENSE_NOTE,
|
|
limitation_message=product.limitation_message,
|
|
).model_dump()
|
|
for product in GrbAcquisitionService._products().values()
|
|
]
|
|
|
|
@staticmethod
|
|
def _product(product_key: str) -> GrbProduct:
|
|
product = GrbAcquisitionService._products().get(product_key.strip().lower())
|
|
if product is None:
|
|
raise AppError(
|
|
code="GRB_PRODUCT_NOT_SUPPORTED",
|
|
message="Select buildings, roads, water or parcels from the governed GRB product registry",
|
|
details={"product_key": product_key},
|
|
status_code=422,
|
|
)
|
|
return product
|
|
|
|
@staticmethod
|
|
def _validate_scope(
|
|
db,
|
|
project_id: UUID,
|
|
payload: GrbAcquireRequest,
|
|
settings: Settings,
|
|
) -> tuple[Any, list[float], list[float]]:
|
|
if not settings.grb_enabled:
|
|
raise AppError(code="GRB_NOT_CONFIGURED", message="Bounded GRB acquisition is disabled", status_code=503)
|
|
if not db.get(Project, project_id):
|
|
raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404)
|
|
if payload.bbox.crs.upper() != "EPSG:4326":
|
|
raise AppError(code="GRB_INVALID_CRS", message="GRB acquisition requires EPSG:4326", status_code=400)
|
|
values = (payload.bbox.min_x, payload.bbox.min_y, payload.bbox.max_x, payload.bbox.max_y)
|
|
if not all(math.isfinite(value) for value in values):
|
|
raise AppError(code="GRB_INVALID_BBOX", message="Bounding box values must be finite", status_code=400)
|
|
if values[0] >= values[2] or values[1] >= values[3]:
|
|
raise AppError(code="GRB_INVALID_BBOX", message="Bounding box has no area", status_code=400)
|
|
if values[0] < -180 or values[2] > 180 or values[1] < -90 or values[3] > 90:
|
|
raise AppError(code="GRB_INVALID_BBOX", message="Bounding box is outside EPSG:4326", status_code=400)
|
|
|
|
transformer = Transformer.from_crs("EPSG:4326", "EPSG:31370", always_xy=True)
|
|
metric_bounds = transformer.transform_bounds(*values, densify_pts=21)
|
|
width_m = float(metric_bounds[2] - metric_bounds[0])
|
|
height_m = float(metric_bounds[3] - metric_bounds[1])
|
|
if width_m < settings.grb_min_side_m or height_m < settings.grb_min_side_m:
|
|
raise AppError(
|
|
code="GRB_SELECTION_TOO_SMALL",
|
|
message=f"Select an area of at least {settings.grb_min_side_m:g} by {settings.grb_min_side_m:g} metres",
|
|
status_code=422,
|
|
)
|
|
if width_m > settings.grb_max_side_m or height_m > settings.grb_max_side_m:
|
|
raise AppError(
|
|
code="GRB_SELECTION_TOO_LARGE",
|
|
message=f"Select an area no larger than {settings.grb_max_side_m:g} by {settings.grb_max_side_m:g} metres",
|
|
details={"width_m": width_m, "height_m": height_m},
|
|
status_code=422,
|
|
)
|
|
|
|
selection = box(*values)
|
|
if payload.area_id is None:
|
|
scope_geometry = selection
|
|
else:
|
|
area = db.get(Area, payload.area_id)
|
|
if area is None:
|
|
raise AppError(code="AREA_NOT_FOUND", message="Area not found", status_code=404)
|
|
if area.project_id != project_id:
|
|
raise AppError(code="INVALID_DATASET_SCOPE", message="Area does not belong to this project", status_code=400)
|
|
scope_geometry = to_shape(area.geometry).intersection(selection)
|
|
if scope_geometry.is_empty:
|
|
raise AppError(
|
|
code="GRB_SCOPE_EMPTY",
|
|
message="The requested bounding box does not intersect the selected area",
|
|
status_code=400,
|
|
)
|
|
return scope_geometry, [float(value) for value in values], [float(value) for value in metric_bounds]
|
|
|
|
@staticmethod
|
|
def _geometry_dimension(geometry: Any) -> int:
|
|
if geometry is None or geometry.is_empty:
|
|
return -1
|
|
if "Polygon" in geometry.geom_type:
|
|
return 2
|
|
if "LineString" in geometry.geom_type or geometry.geom_type == "LinearRing":
|
|
return 1
|
|
if "Point" in geometry.geom_type:
|
|
return 0
|
|
if hasattr(geometry, "geoms"):
|
|
return max((GrbAcquisitionService._geometry_dimension(item) for item in geometry.geoms), default=-1)
|
|
return -1
|
|
|
|
@staticmethod
|
|
def _extract_dimension(geometry: Any, expected_dimension: int) -> Any | None:
|
|
if geometry is None or geometry.is_empty:
|
|
return None
|
|
if not geometry.is_valid:
|
|
geometry = make_valid(geometry)
|
|
parts: list[Any] = []
|
|
|
|
def collect(candidate: Any) -> None:
|
|
if candidate is None or candidate.is_empty:
|
|
return
|
|
if expected_dimension == 2:
|
|
if isinstance(candidate, Polygon):
|
|
parts.append(candidate)
|
|
return
|
|
if isinstance(candidate, MultiPolygon):
|
|
parts.extend(item for item in candidate.geoms if not item.is_empty)
|
|
return
|
|
if expected_dimension == 1:
|
|
if isinstance(candidate, LineString):
|
|
parts.append(candidate)
|
|
return
|
|
if isinstance(candidate, MultiLineString):
|
|
parts.extend(item for item in candidate.geoms if not item.is_empty)
|
|
return
|
|
if hasattr(candidate, "geoms"):
|
|
for item in candidate.geoms:
|
|
collect(item)
|
|
|
|
collect(geometry)
|
|
if not parts:
|
|
return None
|
|
normalized = unary_union(parts)
|
|
if normalized.is_empty:
|
|
return None
|
|
if not normalized.is_valid:
|
|
normalized = make_valid(normalized)
|
|
if (
|
|
normalized.is_empty
|
|
or not normalized.is_valid
|
|
or GrbAcquisitionService._geometry_dimension(normalized) != expected_dimension
|
|
):
|
|
return None
|
|
return normalized
|
|
|
|
@staticmethod
|
|
def _collection_url(settings: Settings, collection: GrbCollection, bbox_values: tuple[float, ...]) -> str:
|
|
base = settings.grb_ogc_api_url.rstrip("/")
|
|
query = urlencode(
|
|
{
|
|
"f": "application/geo+json",
|
|
"limit": str(settings.grb_page_size),
|
|
"bbox": ",".join(f"{value:.8f}" for value in bbox_values),
|
|
"bbox-crs": GrbAcquisitionService.OGC_CRS84_URI,
|
|
"crs": GrbAcquisitionService.OGC_CRS84_URI,
|
|
}
|
|
)
|
|
return f"{base}/collections/{collection.name}/items?{query}"
|
|
|
|
@staticmethod
|
|
def _validated_page_url(
|
|
url: str,
|
|
settings: Settings,
|
|
product: GrbProduct,
|
|
bbox_values: tuple[float, ...],
|
|
) -> str:
|
|
parsed = urlparse(url)
|
|
base = urlparse(settings.grb_ogc_api_url)
|
|
allowed_paths = {
|
|
f"{base.path.rstrip('/')}/collections/{collection.name}/items"
|
|
for collection in product.collections
|
|
}
|
|
if (
|
|
parsed.scheme != "https"
|
|
or base.scheme != "https"
|
|
or parsed.netloc.casefold() != base.netloc.casefold()
|
|
or parsed.path not in allowed_paths
|
|
):
|
|
raise AppError(
|
|
code="GRB_PROVIDER_INVALID_PAGINATION",
|
|
message="GRB returned a pagination URL outside the governed OGC API allowlist",
|
|
status_code=502,
|
|
)
|
|
query = dict(parse_qsl(parsed.query, keep_blank_values=True))
|
|
query.update(
|
|
{
|
|
"f": "application/geo+json",
|
|
"limit": str(settings.grb_page_size),
|
|
"bbox": ",".join(f"{value:.8f}" for value in bbox_values),
|
|
"bbox-crs": GrbAcquisitionService.OGC_CRS84_URI,
|
|
"crs": GrbAcquisitionService.OGC_CRS84_URI,
|
|
}
|
|
)
|
|
return urlunparse((parsed.scheme, parsed.netloc, parsed.path, "", urlencode(query), ""))
|
|
|
|
@staticmethod
|
|
def _read_page(
|
|
url: str,
|
|
settings: Settings,
|
|
opener: Callable[..., Any] | None,
|
|
) -> tuple[dict[str, Any], str, int]:
|
|
request = Request(
|
|
url,
|
|
headers={
|
|
"Accept": "application/geo+json, application/json",
|
|
"User-Agent": "GeoIntel/1.0 bounded-grb-acquisition",
|
|
},
|
|
)
|
|
try:
|
|
with (opener or guarded_opener(url, allow_redirect=False))(
|
|
request,
|
|
timeout=settings.grb_timeout_seconds,
|
|
) as response:
|
|
limit = settings.grb_max_response_mb * 1024 * 1024
|
|
content = response.read(limit + 1)
|
|
except HTTPError as exc:
|
|
raise AppError(
|
|
code="GRB_PROVIDER_HTTP_ERROR",
|
|
message="The GRB OGC API returned an HTTP error",
|
|
details={"status_code": exc.code},
|
|
status_code=502,
|
|
) from exc
|
|
except (TimeoutError, URLError, OSError) as exc:
|
|
raise AppError(
|
|
code="GRB_PROVIDER_UNAVAILABLE",
|
|
message="The GRB OGC API is unavailable",
|
|
status_code=502,
|
|
) from exc
|
|
if len(content) > limit:
|
|
raise AppError(
|
|
code="GRB_PROVIDER_RESPONSE_TOO_LARGE",
|
|
message="A GRB response page exceeded the configured size limit",
|
|
status_code=502,
|
|
)
|
|
try:
|
|
payload = json.loads(content.decode("utf-8"))
|
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
raise AppError(
|
|
code="GRB_PROVIDER_INVALID_RESPONSE",
|
|
message="The GRB OGC API returned invalid GeoJSON",
|
|
status_code=502,
|
|
) from exc
|
|
if not isinstance(payload, dict) or payload.get("type") != "FeatureCollection":
|
|
raise AppError(
|
|
code="GRB_PROVIDER_INVALID_RESPONSE",
|
|
message="The GRB OGC API returned a non-FeatureCollection response",
|
|
status_code=502,
|
|
)
|
|
return payload, hashlib.sha256(content).hexdigest(), len(content)
|
|
|
|
@staticmethod
|
|
def _next_url(payload: dict[str, Any], current_url: str) -> str | None:
|
|
links = payload.get("links")
|
|
if not isinstance(links, list):
|
|
return None
|
|
for link in links:
|
|
if isinstance(link, dict) and link.get("rel") == "next" and link.get("href"):
|
|
return urljoin(current_url, str(link["href"]))
|
|
return None
|
|
|
|
@staticmethod
|
|
def _fetch_features(
|
|
product: GrbProduct,
|
|
scope_geometry: Any,
|
|
bbox_values: tuple[float, ...],
|
|
coverage_scope: str,
|
|
settings: Settings,
|
|
opener: Callable[..., Any] | None,
|
|
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
|
retained: list[dict[str, Any]] = []
|
|
seen_ids: set[str] = set()
|
|
request_urls: list[str] = []
|
|
response_sha256: list[str] = []
|
|
candidate_feature_count = 0
|
|
total_response_bytes = 0
|
|
geometry_types: dict[str, int] = {}
|
|
collection_counts: dict[str, int] = {item.name: 0 for item in product.collections}
|
|
|
|
for collection in product.collections:
|
|
url: str | None = GrbAcquisitionService._collection_url(settings, collection, bbox_values)
|
|
seen_pages: set[str] = set()
|
|
while url:
|
|
url = GrbAcquisitionService._validated_page_url(url, settings, product, bbox_values)
|
|
if url in seen_pages:
|
|
raise AppError(
|
|
code="GRB_PROVIDER_PAGINATION_LOOP",
|
|
message="The GRB OGC API repeated a pagination URL",
|
|
status_code=502,
|
|
)
|
|
if len(request_urls) >= settings.grb_max_pages:
|
|
raise AppError(
|
|
code="GRB_SELECTION_TOO_LARGE",
|
|
message="GRB acquisition exceeded the configured page limit",
|
|
details={"max_pages": settings.grb_max_pages},
|
|
status_code=422,
|
|
)
|
|
seen_pages.add(url)
|
|
payload, response_hash, response_size = GrbAcquisitionService._read_page(url, settings, opener)
|
|
request_urls.append(url)
|
|
response_sha256.append(response_hash)
|
|
total_response_bytes += response_size
|
|
if total_response_bytes > settings.grb_max_total_response_mb * 1024 * 1024:
|
|
raise AppError(
|
|
code="GRB_PROVIDER_RESPONSE_TOO_LARGE",
|
|
message="The complete GRB response exceeded the configured transfer limit",
|
|
status_code=502,
|
|
)
|
|
|
|
source_features = payload.get("features")
|
|
if not isinstance(source_features, list):
|
|
raise AppError(
|
|
code="GRB_PROVIDER_INVALID_RESPONSE",
|
|
message="The GRB FeatureCollection has no valid feature list",
|
|
status_code=502,
|
|
)
|
|
for source_feature in source_features:
|
|
candidate_feature_count += 1
|
|
if not isinstance(source_feature, dict):
|
|
continue
|
|
raw_id = str(source_feature.get("id") or "").strip()
|
|
if not raw_id:
|
|
raise AppError(
|
|
code="GRB_PROVIDER_INVALID_RESPONSE",
|
|
message=f"GRB {collection.name} returned a feature without an official identity",
|
|
status_code=502,
|
|
)
|
|
feature_id = f"{collection.name}:{raw_id}"
|
|
if feature_id in seen_ids:
|
|
continue
|
|
seen_ids.add(feature_id)
|
|
try:
|
|
source_geometry = GrbAcquisitionService._extract_dimension(
|
|
shape(source_feature.get("geometry")),
|
|
collection.geometry_dimension,
|
|
)
|
|
except Exception as exc:
|
|
raise AppError(
|
|
code="GRB_PROVIDER_INVALID_GEOMETRY",
|
|
message=f"GRB {collection.name} returned invalid geometry",
|
|
status_code=502,
|
|
) from exc
|
|
if source_geometry is None or not source_geometry.intersects(scope_geometry):
|
|
continue
|
|
retained_geometry = GrbAcquisitionService._extract_dimension(
|
|
source_geometry.intersection(scope_geometry),
|
|
collection.geometry_dimension,
|
|
)
|
|
if retained_geometry is None:
|
|
continue
|
|
if len(retained) >= settings.grb_max_features:
|
|
raise AppError(
|
|
code="GRB_SELECTION_TOO_LARGE",
|
|
message="GRB selection exceeds the configured feature limit; draw a smaller rectangle",
|
|
details={"max_features": settings.grb_max_features},
|
|
status_code=422,
|
|
)
|
|
properties = dict(source_feature.get("properties") or {})
|
|
properties.update(
|
|
{
|
|
"source_name": GrbAcquisitionService.PROVIDER,
|
|
"source_collection": collection.name,
|
|
"source_feature_id": feature_id,
|
|
"reference_layer_name": product.reference_layer_name,
|
|
"layer_type": product.layer_type,
|
|
"theme": product.key,
|
|
"authority_level": GrbAcquisitionService.AUTHORITY_LEVEL,
|
|
"coverage_scope": coverage_scope,
|
|
"geometry_clipped_to_selection": not scope_geometry.covers(source_geometry),
|
|
"attribution": GrbAcquisitionService.ATTRIBUTION,
|
|
}
|
|
)
|
|
retained.append(
|
|
{
|
|
"type": "Feature",
|
|
"id": feature_id,
|
|
"geometry": mapping(retained_geometry),
|
|
"properties": properties,
|
|
}
|
|
)
|
|
collection_counts[collection.name] += 1
|
|
geometry_types[retained_geometry.geom_type] = geometry_types.get(retained_geometry.geom_type, 0) + 1
|
|
url = GrbAcquisitionService._next_url(payload, url)
|
|
|
|
return retained, {
|
|
"candidate_feature_count": candidate_feature_count,
|
|
"feature_count": len(retained),
|
|
"page_count": len(request_urls),
|
|
"request_urls": request_urls,
|
|
"response_sha256": response_sha256,
|
|
"response_size_bytes": total_response_bytes,
|
|
"collection_feature_counts": collection_counts,
|
|
"geometry_types": geometry_types,
|
|
"output_crs": GrbAcquisitionService.OGC_CRS84_URI,
|
|
"reference_truncated": False,
|
|
}
|
|
|
|
@staticmethod
|
|
def _cached_dataset(
|
|
db,
|
|
project_id: UUID,
|
|
product: GrbProduct,
|
|
request_hash: str,
|
|
settings: Settings,
|
|
) -> Dataset | None:
|
|
if settings.grb_cache_ttl_hours <= 0:
|
|
return None
|
|
candidates = (
|
|
db.query(Dataset)
|
|
.filter(
|
|
Dataset.project_id == project_id,
|
|
Dataset.source_name == GrbAcquisitionService.PROVIDER,
|
|
Dataset.reference_layer_name == product.reference_layer_name,
|
|
Dataset.status == "ready",
|
|
)
|
|
.order_by(Dataset.imported_at.desc())
|
|
.all()
|
|
)
|
|
cutoff = datetime.now(UTC) - timedelta(hours=settings.grb_cache_ttl_hours)
|
|
for candidate in candidates:
|
|
provenance = candidate.provenance_metadata if isinstance(candidate.provenance_metadata, dict) else {}
|
|
imported_at = candidate.imported_at
|
|
if (
|
|
provenance.get("request_hash") == request_hash
|
|
and candidate.storage_path
|
|
and Path(candidate.storage_path).is_file()
|
|
and imported_at is not None
|
|
and imported_at >= cutoff
|
|
):
|
|
return candidate
|
|
return None
|
|
|
|
@staticmethod
|
|
def _result(
|
|
dataset: Dataset,
|
|
product: GrbProduct,
|
|
*,
|
|
reused: bool,
|
|
bbox_values: list[float],
|
|
) -> dict[str, Any]:
|
|
source_metadata = dataset.source_metadata if isinstance(dataset.source_metadata, dict) else {}
|
|
provenance = dataset.provenance_metadata if isinstance(dataset.provenance_metadata, dict) else {}
|
|
metadata = dataset.metadata_json if isinstance(dataset.metadata_json, dict) else {}
|
|
return GrbAcquisitionResult(
|
|
output_dataset_id=dataset.id,
|
|
reused=reused,
|
|
provider=GrbAcquisitionService.PROVIDER,
|
|
product_key=product.key,
|
|
display_name=product.display_name,
|
|
reference_layer_name=product.reference_layer_name,
|
|
collections=[collection.name for collection in product.collections],
|
|
feature_count=int(metadata.get("feature_count", source_metadata.get("feature_count", 0))),
|
|
candidate_feature_count=int(provenance.get("candidate_feature_count", 0)),
|
|
page_count=int(provenance.get("page_count", 0)),
|
|
bbox_epsg4326=bbox_values,
|
|
source_version=str(dataset.source_version or ""),
|
|
attribution=GrbAcquisitionService.ATTRIBUTION,
|
|
limitation_message=product.limitation_message,
|
|
).model_dump(mode="json")
|
|
|
|
@staticmethod
|
|
def acquire(
|
|
db,
|
|
project_id: UUID,
|
|
payload: GrbAcquireRequest,
|
|
*,
|
|
settings: Settings | None = None,
|
|
opener: Callable[..., Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
resolved_settings = settings or get_settings()
|
|
product = GrbAcquisitionService._product(payload.product_key)
|
|
scope_geometry, bbox_values, metric_bounds = GrbAcquisitionService._validate_scope(
|
|
db,
|
|
project_id,
|
|
payload,
|
|
resolved_settings,
|
|
)
|
|
request_identity = {
|
|
"provider": GrbAcquisitionService.PROVIDER,
|
|
"product_key": product.key,
|
|
"bbox_epsg4326": [round(value, 8) for value in bbox_values],
|
|
"area_id": str(payload.area_id) if payload.area_id else None,
|
|
}
|
|
request_hash = hashlib.sha256(json.dumps(request_identity, sort_keys=True).encode()).hexdigest()
|
|
if not payload.force_refresh:
|
|
cached = GrbAcquisitionService._cached_dataset(
|
|
db,
|
|
project_id,
|
|
product,
|
|
request_hash,
|
|
resolved_settings,
|
|
)
|
|
if cached is not None:
|
|
return GrbAcquisitionService._result(cached, product, reused=True, bbox_values=bbox_values)
|
|
|
|
area = db.get(Area, payload.area_id) if payload.area_id else None
|
|
coverage_scope = (
|
|
"municipality"
|
|
if area is not None and area.name.strip().lower().startswith("gemeente ")
|
|
else "bounded_selection"
|
|
)
|
|
features, transfer = GrbAcquisitionService._fetch_features(
|
|
product,
|
|
scope_geometry,
|
|
tuple(scope_geometry.bounds),
|
|
coverage_scope,
|
|
resolved_settings,
|
|
opener,
|
|
)
|
|
acquired_at = datetime.now(UTC)
|
|
source_version = acquired_at.date().isoformat()
|
|
feature_collection = {
|
|
"type": "FeatureCollection",
|
|
"name": product.display_name,
|
|
"crs": {"type": "name", "properties": {"name": "EPSG:4326"}},
|
|
"features": features,
|
|
}
|
|
artifact = json.dumps(feature_collection, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
filename = f"grb_{product.key}_{source_version}_{request_hash[:12]}.geojson"
|
|
source_metadata: dict[str, Any] = {
|
|
"provider": "Digitaal Vlaanderen",
|
|
"service": "OGC API Features",
|
|
"product_key": product.key,
|
|
"product_display_name": product.display_name,
|
|
"collections": [collection.name for collection in product.collections],
|
|
"authority_level": GrbAcquisitionService.AUTHORITY_LEVEL,
|
|
"theme": product.key,
|
|
"layer_type": product.layer_type,
|
|
"coverage_scope": coverage_scope,
|
|
"geometry_clipped_to_area": payload.area_id is not None,
|
|
"geometry_clipped_to_selection": True,
|
|
"bbox_epsg4326": bbox_values,
|
|
"bbox_epsg31370": metric_bounds,
|
|
"feature_count": len(features),
|
|
"collection_feature_counts": transfer["collection_feature_counts"],
|
|
"identity_stable": True,
|
|
"identity_scheme": "grb_ogc_feature_id",
|
|
"source_storage_crs": "EPSG:31370",
|
|
"requested_output_crs": GrbAcquisitionService.OGC_CRS84_URI,
|
|
"selection_aggregation": {
|
|
"metric_key": product.metric_key,
|
|
"method": product.metric_method,
|
|
"label": product.metric_label,
|
|
"unit": product.metric_unit,
|
|
"geometry_dimension": product.metric_dimension,
|
|
"is_estimate": False,
|
|
"warning": product.metric_warning,
|
|
},
|
|
"attribution": GrbAcquisitionService.ATTRIBUTION,
|
|
"license_note": GrbAcquisitionService.LICENSE_NOTE,
|
|
"catalog_url": GrbAcquisitionService.CATALOG_URL,
|
|
"limitation_message": product.limitation_message,
|
|
}
|
|
if product.key == "water":
|
|
source_metadata["selection_metrics"] = [
|
|
{
|
|
"metric_key": "water_length",
|
|
"method": "intersection_length",
|
|
"label": "Lengte watergerelateerde lijnen",
|
|
"unit": "km",
|
|
"geometry_dimension": 1,
|
|
"is_estimate": False,
|
|
}
|
|
]
|
|
try:
|
|
dataset_response = DatasetService.import_vector_bytes(
|
|
db,
|
|
project_id=project_id,
|
|
area_id=payload.area_id,
|
|
filename=filename,
|
|
content=artifact,
|
|
source="Digitaal Vlaanderen GRB OGC API Features",
|
|
source_name=GrbAcquisitionService.PROVIDER,
|
|
dataset_role="reference",
|
|
reference_layer_name=product.reference_layer_name,
|
|
temporal_series_key=f"grb:{product.key}:{request_hash[:24]}",
|
|
observed_at=datetime(
|
|
acquired_at.year,
|
|
acquired_at.month,
|
|
acquired_at.day,
|
|
tzinfo=UTC,
|
|
),
|
|
temporal_granularity="snapshot",
|
|
source_version=source_version,
|
|
source_metadata=source_metadata,
|
|
provenance_metadata={
|
|
"acquisition": "explicit_bounded_ogc_api_features",
|
|
"acquired_at": acquired_at.isoformat(),
|
|
"request_hash": request_hash,
|
|
"request_urls": transfer["request_urls"],
|
|
"response_sha256": transfer["response_sha256"],
|
|
"response_size_bytes": transfer["response_size_bytes"],
|
|
"page_count": transfer["page_count"],
|
|
"candidate_feature_count": transfer["candidate_feature_count"],
|
|
"exact_feature_count": transfer["feature_count"],
|
|
"reference_truncated": False,
|
|
"artifact_sha256": hashlib.sha256(artifact).hexdigest(),
|
|
"clipped_to_area_id": str(payload.area_id) if payload.area_id else None,
|
|
"scope_geometry_type": scope_geometry.geom_type,
|
|
"limitation_message": product.limitation_message,
|
|
},
|
|
)
|
|
except AppError:
|
|
raise
|
|
except Exception as exc:
|
|
raise AppError(
|
|
code="GRB_PERSISTENCE_FAILED",
|
|
message="The validated GRB selection could not be persisted",
|
|
details={"reason": str(exc)},
|
|
status_code=500,
|
|
) from exc
|
|
persisted = db.get(Dataset, dataset_response.id)
|
|
if persisted is None:
|
|
raise AppError(
|
|
code="GRB_PERSISTENCE_FAILED",
|
|
message="The persisted GRB dataset could not be reloaded",
|
|
status_code=500,
|
|
)
|
|
return GrbAcquisitionService._result(persisted, product, reused=False, bbox_values=bbox_values)
|