Files
geointel/backend/app/services/dataset_service.py
T
Jens faeb58ef6d
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
Initial public release
2026-08-31 21:56:53 +02:00

3101 lines
138 KiB
Python

from __future__ import annotations
import json
import pathlib
import re
from dataclasses import dataclass
from hashlib import sha256
from datetime import datetime, timezone
from math import isfinite
from pathlib import Path
from typing import Any
from uuid import UUID
import uuid
from fastapi import UploadFile
from shapely.geometry import MultiPoint, shape
from sqlalchemy.orm import Session
from app.core.errors import AppError
from app.core.config import get_settings
from app.models import Area, Dataset, DatasetVersion, Project
from app.services.data_contract_validation import (
ContractKind,
DataAssetValidationInput,
GeometryRecord,
LineageEvidence,
LineageStatus,
ProvenanceStatus,
QuarantineStatus,
RASTER_GEOTIFF_CONTRACT_KEY,
RASTER_GEOTIFF_CONTRACT_VERSION,
TransformationEvidence,
VECTOR_GEOJSON_CONTRACT_KEY,
VECTOR_GEOJSON_CONTRACT_VERSION,
ValidationIssue,
ValidationReport,
ValidationStatus,
build_raster_ingest_input,
build_vector_ingest_input,
validate_registered_asset,
)
from app.services.data_quarantine_service import DataQuarantineService
from app.schemas.dataset import (
DatasetCreateResponse,
DatasetStorageResponse,
DatasetTemporalUpdate,
DatasetVectorSummary,
DatasetVersionRead,
)
from app.services.geojson_service import parse_geojson_payload, load_dataset_text
from app.services.raster_service import extract_raster_metadata
from app.services.source_registry_service import SourceRegistryService
from app.services.storage_service import StorageService
from app.services.vector_feature_service import VectorFeatureService
_CHECKSUM_SHA256 = re.compile(r"^[0-9a-f]{64}$")
@dataclass(frozen=True)
class _SourceVectorSchema:
"""Server-owned vector schema expectations attached to a source registry row."""
source_key: str
expected_geometry_types: frozenset[str]
required_attributes: tuple[str, ...]
@classmethod
def from_source(cls, source: Any) -> "_SourceVectorSchema":
geometry_values = getattr(source, "expected_geometry_types_json", ())
expected_geometry_types = (
frozenset(str(value).strip() for value in geometry_values if str(value).strip())
if isinstance(geometry_values, (list, tuple, set))
else frozenset()
)
attributes = getattr(source, "expected_attributes_json", {})
required_values = attributes.get("required") if isinstance(attributes, dict) else ()
if isinstance(required_values, str):
required_values = (required_values,)
required_attributes = (
tuple(sorted({str(value).strip() for value in required_values if str(value).strip()}))
if isinstance(required_values, (list, tuple, set))
else ()
)
return cls(
source_key=str(getattr(source, "source_key", "") or "").strip().lower(),
expected_geometry_types=expected_geometry_types,
required_attributes=required_attributes,
)
def to_metadata(self, *, checked_feature_count: int) -> dict[str, Any]:
return {
"status": "passed",
"source_key": self.source_key,
"expected_geometry_types": sorted(self.expected_geometry_types),
"required_attributes": list(self.required_attributes),
"checked_feature_count": checked_feature_count,
}
def _feature_source_identifier(feature: dict[str, Any], properties: dict[str, Any]) -> Any:
"""Return the source identity under the GeoJSON and registry conventions."""
return feature.get("id") or properties.get("id") or properties.get("source_feature_id")
def _validate_vector_feature_source_schema(
*,
feature: dict[str, Any],
properties: dict[str, Any],
geometry: Any,
schema: _SourceVectorSchema,
feature_context: str,
) -> None:
"""Fail closed when a source-specific vector expectation is violated."""
if schema.expected_geometry_types and geometry.geom_type not in schema.expected_geometry_types:
raise AppError(
code="SOURCE_SCHEMA_GEOMETRY_TYPE_NOT_ALLOWED",
message=(
f"{feature_context} has geometry type {geometry.geom_type}, which is not "
f"allowed by source registry {schema.source_key or 'unknown'}"
),
details={
"source_key": schema.source_key,
"expected_geometry_types": sorted(schema.expected_geometry_types),
"observed_geometry_type": geometry.geom_type,
},
status_code=400,
)
for attribute in schema.required_attributes:
value = _feature_source_identifier(feature, properties) if attribute == "id" else properties.get(attribute)
if value is None or (isinstance(value, str) and not value.strip()):
raise AppError(
code="SOURCE_SCHEMA_REQUIRED_ATTRIBUTE_MISSING",
message=(
f"{feature_context} is missing required source attribute {attribute!r} "
f"for registry {schema.source_key or 'unknown'}"
),
details={"source_key": schema.source_key, "required_attribute": attribute},
status_code=400,
)
@dataclass(frozen=True)
class _PartitionedVectorAudit:
"""Aggregate evidence from a full per-feature partition audit.
The importer materializes one GeoJSON partition at a time because the
current parser is ``json.loads`` based. It never materializes every
regional partition or every regional Shapely geometry at once.
"""
feature_count: int
geometry_types: tuple[str, ...]
bounds_json: dict[str, float]
partition_checksums_sha256: dict[str, str]
source_schema_validation: dict[str, Any]
representative_record: GeometryRecord
def to_metadata(self) -> dict[str, Any]:
return {
"feature_count": self.feature_count,
"geometry_types": list(self.geometry_types),
"bounds_json": dict(self.bounds_json),
"partition_checksums_sha256": dict(self.partition_checksums_sha256),
"source_schema_validation": dict(self.source_schema_validation),
"validation_mode": "partition_bounded_per_feature_with_aggregate_contract_record",
}
class _PartitionedGeoJsonRecords:
"""Perform a full, partition-bounded feature audit across GeoJSON partitions.
The generic vector contract materializes its supplied geometry records. A
regional artifact can contain hundreds of thousands of features, so this
class validates one materialized partition at a time and emits a compact
aggregate record for the generic source/checksum/CRS/bounds contract.
Memory is bounded to the largest single partition, not to one feature.
"""
def __init__(
self,
partition_paths: list[str | Path],
*,
expected_feature_count: int,
declared_partition_checksums: dict[str, Any] | None,
source_schema: _SourceVectorSchema | None = None,
) -> None:
self._partition_paths = tuple(Path(path) for path in partition_paths)
self._expected_feature_count = expected_feature_count
self._declared_checksums = declared_partition_checksums
self._source_schema = source_schema or _SourceVectorSchema(
source_key="",
expected_geometry_types=frozenset(),
required_attributes=(),
)
def audit(self) -> _PartitionedVectorAudit:
declared_checksums = self._validated_declared_checksums()
observed_checksums: dict[str, str] = {}
feature_count = 0
source_feature_ids: set[str] = set()
geometry_types: set[str] = set()
min_x: float | None = None
min_y: float | None = None
max_x: float | None = None
max_y: float | None = None
for partition_path in self._partition_paths:
try:
raw = partition_path.read_bytes()
payload = json.loads(raw.decode("utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
raise AppError(
code="INVALID_GEOJSON_PARTITION",
message=f"Could not read GeoJSON partition {partition_path.name}",
status_code=400,
) from exc
features = payload.get("features") if isinstance(payload, dict) else None
if not isinstance(payload, dict) or payload.get("type") != "FeatureCollection" or not isinstance(features, list):
raise AppError(
code="INVALID_GEOJSON_PARTITION",
message=f"GeoJSON partition {partition_path.name} must be a FeatureCollection",
status_code=400,
)
observed_checksum = sha256(raw).hexdigest()
expected_checksum = declared_checksums[partition_path.name]
if observed_checksum != expected_checksum:
raise AppError(
code="PARTITION_CHECKSUM_MISMATCH",
message=(
f"Checksum for partition {partition_path.name} does not match "
"the governed acquisition manifest."
),
status_code=400,
)
observed_checksums[partition_path.name] = observed_checksum
for index, feature in enumerate(features):
if not isinstance(feature, dict):
raise AppError(
code="INVALID_GEOJSON_PARTITION",
message=f"Feature {index} in {partition_path.name} must be an object",
status_code=400,
)
properties = feature.get("properties") if isinstance(feature.get("properties"), dict) else {}
source_feature_id = _feature_source_identifier(feature, properties)
if source_feature_id is not None:
normalized_id = str(source_feature_id).strip()
if normalized_id:
if normalized_id in source_feature_ids:
raise AppError(
code="DUPLICATE_SOURCE_FEATURE",
message=(
f"Duplicate source feature {normalized_id} across regional partitions"
),
status_code=400,
)
source_feature_ids.add(normalized_id)
try:
geometry = shape(feature.get("geometry"))
except Exception as exc:
raise AppError(
code="GEOMETRY_PARSE_FAILED",
message=f"Feature {index} in {partition_path.name} has invalid GeoJSON geometry",
status_code=400,
) from exc
if geometry.is_empty:
raise AppError(
code="GEOMETRY_EMPTY",
message=f"Feature {index} in {partition_path.name} has an empty geometry",
status_code=400,
)
if not geometry.is_valid:
raise AppError(
code="GEOMETRY_INVALID",
message=(
f"Feature {index} in {partition_path.name} is invalid; "
"partitioned ingestion never silently repairs geometry"
),
status_code=400,
)
_validate_vector_feature_source_schema(
feature=feature,
properties=properties,
geometry=geometry,
schema=self._source_schema,
feature_context=f"Feature {index} in {partition_path.name}",
)
feature_bounds = geometry.bounds
if not all(isfinite(value) for value in feature_bounds):
raise AppError(
code="GEOMETRY_BOUNDS_INVALID",
message=f"Feature {index} in {partition_path.name} has non-finite bounds",
status_code=400,
)
geometry_types.add(geometry.geom_type)
min_x = feature_bounds[0] if min_x is None else min(min_x, feature_bounds[0])
min_y = feature_bounds[1] if min_y is None else min(min_y, feature_bounds[1])
max_x = feature_bounds[2] if max_x is None else max(max_x, feature_bounds[2])
max_y = feature_bounds[3] if max_y is None else max(max_y, feature_bounds[3])
feature_count += 1
if feature_count != self._expected_feature_count:
raise AppError(
code="PARTITION_FEATURE_COUNT_MISMATCH",
message=(
f"Regional artifact declares {self._expected_feature_count} features but "
f"partitions contain {feature_count} features"
),
status_code=400,
)
if None in {min_x, min_y, max_x, max_y}: # pragma: no cover - feature-count invariant above
raise AppError(
code="VECTOR_FEATURES_REQUIRED",
message="Partitioned vector artifact has no geometry records.",
status_code=400,
)
bounds_json = {
"min_x": float(min_x),
"min_y": float(min_y),
"max_x": float(max_x),
"max_y": float(max_y),
}
# A MultiPoint envelope is validation evidence only, not a replacement
# for persisted source features. It gives the generic contract the
# audited aggregate bounds without retaining all Shapely objects.
representative_geometry = MultiPoint(
[
(bounds_json["min_x"], bounds_json["min_y"]),
(bounds_json["max_x"], bounds_json["min_y"]),
(bounds_json["max_x"], bounds_json["max_y"]),
(bounds_json["min_x"], bounds_json["max_y"]),
]
)
return _PartitionedVectorAudit(
feature_count=feature_count,
geometry_types=tuple(sorted(geometry_types)),
bounds_json=bounds_json,
partition_checksums_sha256=dict(sorted(observed_checksums.items())),
source_schema_validation=self._source_schema.to_metadata(checked_feature_count=feature_count),
representative_record=GeometryRecord(
geometry=representative_geometry,
properties={"partitioned_geometry_audit": True},
identifier="partitioned-geometry-audit",
),
)
def _validated_declared_checksums(self) -> dict[str, str]:
"""Require an exact filename-to-SHA256 manifest for every partition.
A list of checksum values is insufficient: it cannot establish which
municipality/source partition produced which persisted feature set.
The explicit map is also retained with the aggregate audit evidence.
"""
if not isinstance(self._declared_checksums, dict) or not self._declared_checksums:
raise AppError(
code="PARTITION_CHECKSUM_MANIFEST_REQUIRED",
message="Partitioned ingestion requires a non-empty filename-to-checksum manifest.",
status_code=400,
)
partition_names = [path.name for path in self._partition_paths]
if len(set(partition_names)) != len(partition_names):
raise AppError(
code="DUPLICATE_PARTITION_IDENTITY",
message="Partitioned ingestion requires unique partition filenames.",
status_code=400,
)
declared = {
str(key): str(value).strip().lower()
for key, value in self._declared_checksums.items()
}
if len(declared) != len(partition_names) or set(declared) != set(partition_names):
raise AppError(
code="PARTITION_CHECKSUM_MANIFEST_MISMATCH",
message="Partition checksum manifest must contain exactly one entry for each partition filename.",
details={
"expected_partition_filenames": sorted(partition_names),
"declared_partition_filenames": sorted(declared),
},
status_code=400,
)
invalid = sorted(name for name, checksum in declared.items() if not _CHECKSUM_SHA256.fullmatch(checksum))
if invalid:
raise AppError(
code="PARTITION_CHECKSUM_INVALID",
message="Partition checksum manifest contains a non-SHA256 value.",
details={"partition_filenames": invalid},
status_code=400,
)
return declared
class DatasetService:
VECTOR_EXTENSIONS = {".geojson", ".json"}
RASTER_EXTENSIONS = {".tif", ".tiff", ".geotiff"}
VECTOR_TYPES = {"vector", "geojson"}
RASTER_TYPES = {"raster", "tif", "tiff", "geotiff"}
VALID_DATASET_ROLES = {"source", "derived", "reference"}
VALID_TEMPORAL_GRANULARITIES = {"snapshot", "day", "month", "year", "period"}
CANONICAL_VECTOR_CRS = "EPSG:4326"
@staticmethod
def _registry_persistence_available(db: Session) -> bool:
"""Return true only for real ORM-backed ingestion transactions.
Production request handling always supplies a SQLAlchemy Session. The
narrow fallback keeps historical lightweight unit fakes (which predate
the registry tables) isolated; it cannot bypass the database-backed
production import path.
"""
return callable(getattr(db, "query", None))
@staticmethod
def _stable_hash(payload: Any) -> str:
return sha256(
json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str).encode("utf-8")
).hexdigest()
@classmethod
def _canonical_vector_storage_bytes(cls, payload: dict[str, Any]) -> bytes:
"""Serialize the consumable GeoJSON representation deterministically.
``VectorFeatureService.canonicalize_geojson_payload`` is the one
place that transforms source coordinates to EPSG:4326. This helper
makes the exact result of that transform the persisted, checksummed
dataset artifact too; it must never remain merely an in-memory view.
"""
return json.dumps(
payload,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
@classmethod
def _vector_storage_requires_canonicalization(cls, source_crs: str | None) -> bool:
"""Return whether the source file cannot itself be the canonical view.
A missing CRS is intentionally treated as the GeoJSON/RFC-7946
default EPSG:4326. Other aliases (for example ``CRS:84``) are
rewritten so every transformed consumption artifact explicitly says
``EPSG:4326``.
"""
return str(source_crs or cls.CANONICAL_VECTOR_CRS).strip().upper() != cls.CANONICAL_VECTOR_CRS
@classmethod
def _persist_vector_source_evidence(
cls,
*,
project_id: UUID,
dataset_id: UUID,
original_filename: str,
content: bytes,
content_type: str | None,
) -> dict[str, Any]:
"""Retain a non-canonical source file outside the consumption path.
The Dataset's normal ``storage_path`` always points at the canonical
artifact. The source bytes are retained only below ``provenance/``
and are referenced through structured provenance metadata; consumers
must never treat this location as a dataset input.
"""
safe_filename = StorageService._safe_filename(original_filename)
evidence_path = (
StorageService.dataset_root(str(project_id), str(dataset_id), "vector")
/ "provenance"
/ f"{dataset_id}_source_{safe_filename}"
)
return StorageService.persist_file(
str(evidence_path),
content,
original_filename=safe_filename,
content_type=content_type,
)
@classmethod
def _persist_vector_source_evidence_from_path(
cls,
*,
project_id: UUID,
dataset_id: UUID,
original_filename: str,
source_path: str | Path,
content_type: str | None,
) -> dict[str, Any]:
safe_filename = StorageService._safe_filename(original_filename)
evidence_path = (
StorageService.dataset_root(str(project_id), str(dataset_id), "vector")
/ "provenance"
/ f"{dataset_id}_source_{safe_filename}"
)
return StorageService.persist_file_from_path(
str(evidence_path),
source_path,
original_filename=safe_filename,
content_type=content_type,
)
@classmethod
def _record_vector_source_evidence(
cls,
*,
source_metadata: dict[str, Any],
provenance_metadata: dict[str, Any],
source_crs: str,
evidence: dict[str, Any],
canonical_checksum_sha256: str,
) -> None:
"""Bind original source bytes to their canonical consumption artifact."""
source_artifact = {
"storage_path": evidence["storage_path"],
"checksum_sha256": evidence["checksum_sha256"],
"size_bytes": evidence["size_bytes"],
"content_type": evidence["content_type"],
"source_crs": source_crs,
"retention": "provenance_evidence_only",
}
transformation = {
"name": "vector_crs_normalization",
"version": "1.0.0",
"source_crs": source_crs,
"storage_crs": cls.CANONICAL_VECTOR_CRS,
"source_checksum_sha256": evidence["checksum_sha256"],
"canonical_checksum_sha256": canonical_checksum_sha256,
}
source_metadata["source_artifact"] = source_artifact
provenance_metadata["source_artifact"] = source_artifact
provenance_metadata["canonical_consumption_artifact"] = {
"checksum_sha256": canonical_checksum_sha256,
"crs": cls.CANONICAL_VECTOR_CRS,
"storage_role": "dataset_consumption",
}
provenance_metadata["transformations"] = [
*(
provenance_metadata.get("transformations")
if isinstance(provenance_metadata.get("transformations"), list)
else []
),
transformation,
]
@staticmethod
def _calculate_file_checksum_sha256(path: str | Path) -> str:
"""Stream an operator artifact before storage for an idempotent ingest key."""
artifact = Path(path)
if not artifact.is_file():
raise AppError(
code="DATASET_FILE_MISSING",
message="Partitioned vector artifact is missing",
details={"artifact_path": str(artifact)},
status_code=404,
)
digest = sha256()
with artifact.open("rb") as stream:
for chunk in iter(lambda: stream.read(8 * 1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
@classmethod
def _ingest_key(
cls,
*,
project_id: UUID,
source_key: str,
checksum_sha256: str,
dataset_type: str,
dataset_role: str,
area_id: UUID | None,
reference_layer_name: str | None,
source_version: str | None,
) -> str:
return cls._stable_hash(
{
"project_id": str(project_id),
"source_key": source_key,
"checksum_sha256": checksum_sha256.lower(),
"dataset_type": dataset_type,
"dataset_role": dataset_role,
"area_id": str(area_id) if area_id else None,
"reference_layer_name": reference_layer_name or None,
"source_version": source_version or None,
"ingest_contract": "phase2-source-provenance-v1",
}
)
@staticmethod
def _contract_metadata(
*,
metadata: dict[str, Any],
source_metadata: dict[str, Any] | None,
provenance_metadata: dict[str, Any] | None,
source: Any,
) -> dict[str, Any]:
result = dict(metadata)
source_values = source_metadata if isinstance(source_metadata, dict) else {}
provenance_values = provenance_metadata if isinstance(provenance_metadata, dict) else {}
result["license"] = (
result.get("license")
or source_values.get("license")
or source_values.get("license_note")
or provenance_values.get("license")
or getattr(source, "license_name", None)
or "unknown"
)
result.setdefault(
"usage_restrictions",
source_values.get("usage_restrictions")
or getattr(source, "usage_restrictions", None)
or "unknown",
)
return result
@staticmethod
def _validate_vector_source_schema(source: Any, feature_collection: dict[str, Any]) -> dict[str, Any]:
"""Validate the server-owned source schema after canonicalization.
Generic GeoJSON validation proves that a feature collection is
structurally valid. This additional pass proves that it also matches
the geometry and required-attribute expectations recorded for the
selected source registry entry. It deliberately uses the
canonical-storage payload so the evidence describes exactly what will
be persisted in ``vector_features``.
"""
schema = _SourceVectorSchema.from_source(source)
features = feature_collection.get("features") if isinstance(feature_collection, dict) else None
if not isinstance(features, list):
raise AppError(
code="SOURCE_SCHEMA_FEATURE_COLLECTION_INVALID",
message="Source-schema validation requires a GeoJSON FeatureCollection.",
status_code=400,
)
for index, feature in enumerate(features):
if not isinstance(feature, dict):
raise AppError(
code="SOURCE_SCHEMA_FEATURE_INVALID",
message=f"Feature {index} is not an object during source-schema validation.",
status_code=400,
)
properties = feature.get("properties") if isinstance(feature.get("properties"), dict) else {}
try:
geometry = shape(feature.get("geometry"))
except Exception as exc:
raise AppError(
code="SOURCE_SCHEMA_GEOMETRY_INVALID",
message=f"Feature {index} has no parseable geometry during source-schema validation.",
status_code=400,
) from exc
_validate_vector_feature_source_schema(
feature=feature,
properties=properties,
geometry=geometry,
schema=schema,
feature_context=f"Feature {index}",
)
return schema.to_metadata(checked_feature_count=len(features))
@staticmethod
def _snapshot_freshness_status(
source_key: str,
source_metadata: dict[str, Any] | None,
*,
observed_at: datetime | None,
source_version: str | None,
) -> str:
metadata = source_metadata if isinstance(source_metadata, dict) else {}
supplied = str(metadata.get("freshness_status") or "").strip().lower()
allowed = {"unknown", "current", "due", "stale", "not_applicable", "review_required"}
if supplied in allowed:
return supplied
if source_key in {"manual", "fixture", "map_selection", "derived", "experimental"}:
return "not_applicable"
return "current" if observed_at is not None or bool((source_version or "").strip()) else "review_required"
@staticmethod
def _resolution_unit_for_crs(crs: str | None) -> str:
normalized = str(crs or "").strip().upper()
return "degree" if normalized in {"EPSG:4326", "CRS:84", "OGC:CRS84"} else "m"
@staticmethod
def _failed_validation_report(
*,
asset_id: str,
dataset_type: str,
code: str,
message: str,
now: datetime,
category: str = "parser",
) -> ValidationReport:
if dataset_type == "vector":
contract_key, contract_version = VECTOR_GEOJSON_CONTRACT_KEY, VECTOR_GEOJSON_CONTRACT_VERSION
else:
contract_key, contract_version = RASTER_GEOTIFF_CONTRACT_KEY, RASTER_GEOTIFF_CONTRACT_VERSION
return ValidationReport(
asset_id=asset_id,
data_contract_key=contract_key,
data_contract_version=contract_version,
contract_fingerprint_sha256=None,
validation_status=ValidationStatus.FAILED,
provenance_status=ProvenanceStatus.INCOMPLETE,
lineage_status=LineageStatus.INCOMPLETE,
quarantine_status=QuarantineStatus.QUARANTINED,
validation_scope=("ingest", dataset_type),
checked_at=now,
issues=(
ValidationIssue(
code=code,
category=category,
field="artifact",
message=message,
),
),
)
@staticmethod
def _normalize_datetime(value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
@staticmethod
def _validate_temporal_metadata(
*,
temporal_series_key: str | None,
observed_at: datetime | None,
valid_from: datetime | None,
valid_to: datetime | None,
temporal_granularity: str | None,
source_version: str | None,
) -> dict[str, Any]:
normalized_key = (temporal_series_key or "").strip() or None
normalized_observed_at = DatasetService._normalize_datetime(observed_at)
normalized_valid_from = DatasetService._normalize_datetime(valid_from)
normalized_valid_to = DatasetService._normalize_datetime(valid_to)
normalized_granularity = (temporal_granularity or "").strip().lower() or None
normalized_source_version = (source_version or "").strip() or None
if normalized_key and len(normalized_key) > 255:
raise AppError(code="INVALID_TEMPORAL_METADATA", message="temporal_series_key is too long", status_code=400)
if normalized_granularity and normalized_granularity not in DatasetService.VALID_TEMPORAL_GRANULARITIES:
raise AppError(
code="INVALID_TEMPORAL_METADATA",
message="temporal_granularity must be snapshot, day, month, year or period",
status_code=400,
)
if normalized_valid_from and normalized_valid_to and normalized_valid_to < normalized_valid_from:
raise AppError(
code="INVALID_TEMPORAL_METADATA",
message="valid_to must be on or after valid_from",
status_code=400,
)
if normalized_key and normalized_observed_at is None:
raise AppError(
code="INVALID_TEMPORAL_METADATA",
message="observed_at is required when temporal_series_key is provided",
status_code=400,
)
if normalized_observed_at and normalized_key is None:
raise AppError(
code="INVALID_TEMPORAL_METADATA",
message="temporal_series_key is required when observed_at is provided",
status_code=400,
)
return {
"temporal_series_key": normalized_key,
"observed_at": normalized_observed_at,
"valid_from": normalized_valid_from,
"valid_to": normalized_valid_to,
"temporal_granularity": normalized_granularity,
"source_version": normalized_source_version,
}
@staticmethod
def _to_response(dataset: Dataset) -> DatasetCreateResponse:
metadata_json = dataset.metadata_json if isinstance(dataset.metadata_json, dict) else {}
return DatasetCreateResponse(
id=dataset.id,
name=dataset.name,
dataset_type=dataset.dataset_type,
source=dataset.source,
dataset_role=dataset.dataset_role,
source_name=dataset.source_name,
reference_layer_name=dataset.reference_layer_name,
source_metadata=dataset.source_metadata,
provenance_metadata=dataset.provenance_metadata,
ingest_key=dataset.ingest_key,
source_registry_id=dataset.source_registry_id,
source_snapshot_id=dataset.source_snapshot_id,
data_contract_key=dataset.data_contract_key,
data_contract_version=dataset.data_contract_version,
validation_status=dataset.validation_status,
validation_report_json=dataset.validation_report_json,
provenance_status=dataset.provenance_status,
lineage_status=dataset.lineage_status,
quarantine_status=dataset.quarantine_status,
imported_at=dataset.imported_at,
temporal_series_key=dataset.temporal_series_key,
observed_at=dataset.observed_at,
valid_from=dataset.valid_from,
valid_to=dataset.valid_to,
temporal_granularity=dataset.temporal_granularity,
source_version=dataset.source_version,
project_id=dataset.project_id,
area_id=dataset.area_id,
storage_path=dataset.storage_path,
original_filename=dataset.original_filename,
stored_filename=dataset.stored_filename,
content_type=dataset.content_type,
size_bytes=dataset.size_bytes,
checksum_sha256=dataset.checksum_sha256,
crs=dataset.crs,
bounds_json=dataset.bounds_json,
metadata_json=dataset.metadata_json,
vector_summary=DatasetService._extract_vector_summary(dataset.dataset_type, metadata_json),
status=dataset.status,
derived_from_dataset_id=dataset.derived_from_dataset_id,
created_at=dataset.created_at,
feature_count=metadata_json.get("feature_count"),
)
@staticmethod
def _canonical_dataset_type(dataset_type: str) -> str:
normalized = (dataset_type or "").strip().lower()
if normalized in DatasetService.VECTOR_TYPES:
return "vector"
if normalized in DatasetService.RASTER_TYPES:
return "raster"
raise AppError(
code="INVALID_DATASET_TYPE",
message="dataset_type must be 'vector' or 'raster' (or legacy 'geojson')",
status_code=400,
)
@staticmethod
def _normalize_stored_dataset_type(dataset_type: str) -> str:
normalized = (dataset_type or "").strip().lower()
if normalized in DatasetService.VECTOR_TYPES:
return "vector"
if normalized in DatasetService.RASTER_TYPES:
return "raster"
return normalized
@staticmethod
def _is_vector_type(dataset_type: str) -> bool:
return DatasetService._normalize_stored_dataset_type(dataset_type) == "vector"
@staticmethod
def _is_raster_type(dataset_type: str) -> bool:
return DatasetService._normalize_stored_dataset_type(dataset_type) == "raster"
@staticmethod
def _normalize_dataset_role(dataset_role: str | None) -> str:
normalized = (dataset_role or "").strip().lower() or "source"
if normalized not in DatasetService.VALID_DATASET_ROLES:
raise AppError(
code="INVALID_DATASET_ROLE",
message="dataset_role must be one of: source, derived, reference",
status_code=400,
)
return normalized
@staticmethod
def _extension_for_path(filename: str) -> str:
return Path(filename).suffix.lower()
@staticmethod
def _validate_upload_filename(filename: str | None) -> str:
if not filename:
raise AppError(code="INVALID_UPLOAD", message="Missing file name", status_code=400)
return filename
@staticmethod
async def _stage_upload(
*,
project_id: UUID,
dataset_id: uuid.UUID,
dataset_type: str,
filename: str,
file: UploadFile,
) -> dict[str, Any]:
settings = get_settings()
max_upload_mb = int(settings.max_upload_mb)
if DatasetService._canonical_dataset_type(dataset_type) == "vector":
max_upload_mb = min(max_upload_mb, int(settings.max_in_memory_vector_mb))
return await StorageService.persist_upload_file(
project_id=str(project_id),
dataset_id=str(dataset_id),
dataset_type=dataset_type,
original_filename=filename,
upload=file,
content_type=file.content_type,
max_bytes=max_upload_mb * 1024 * 1024,
)
@staticmethod
def _read_staged_vector_bytes(storage_info: dict[str, Any]) -> bytes:
settings = get_settings()
max_bytes = min(
int(settings.max_upload_mb),
int(settings.max_in_memory_vector_mb),
) * 1024 * 1024
path = Path(str(storage_info["storage_path"]))
with path.open("rb") as stream:
content = stream.read(max_bytes + 1)
if len(content) > max_bytes:
StorageService.remove_dataset_file(str(path))
raise AppError(
code="UPLOAD_TOO_LARGE",
message="Vector upload exceeds the bounded in-memory parsing limit.",
details={
"max_bytes": max_bytes,
"max_in_memory_vector_mb": max_bytes // (1024 * 1024),
},
status_code=413,
)
return content
@staticmethod
def list_datasets(db: Session, project_id: UUID, limit: int = 50, offset: int = 0) -> tuple[list[DatasetCreateResponse], int]:
total = db.query(Dataset).filter(Dataset.project_id == project_id).count()
rows = (
db.query(Dataset)
.filter(Dataset.project_id == project_id)
.order_by(Dataset.created_at.desc())
.offset(offset)
.limit(limit)
.all()
)
return [DatasetService._to_response(row) for row in rows], total
@staticmethod
def _extract_vector_summary(dataset_type: str, metadata_json: dict) -> DatasetVectorSummary | None:
if not DatasetService._is_vector_type(dataset_type):
return None
if not isinstance(metadata_json, dict):
return None
return DatasetVectorSummary(
feature_count=metadata_json.get("feature_count"),
geometry_types=metadata_json.get("geometry_types"),
bounds_json=metadata_json.get("bounds_json"),
approximate_area_m2=metadata_json.get("approximate_area_m2"),
crs=metadata_json.get("crs"),
feature_geometry_count=metadata_json.get("feature_geometry_count"),
invalid_features=metadata_json.get("invalid_features"),
crs_assumed=metadata_json.get("crs_assumed"),
)
@staticmethod
def _extract_raster_bounds_json(metadata_json: dict[str, Any]) -> dict[str, float] | None:
existing = metadata_json.get("bounds_json")
if isinstance(existing, dict):
return existing
bounds = metadata_json.get("bounds")
if isinstance(bounds, (list, tuple)) and len(bounds) == 4:
return {
"minx": float(bounds[0]),
"miny": float(bounds[1]),
"maxx": float(bounds[2]),
"maxy": float(bounds[3]),
}
return None
@staticmethod
def _extract_raster_resolution_json(metadata_json: dict[str, Any]) -> dict[str, float] | None:
existing = metadata_json.get("resolution_json")
if isinstance(existing, dict):
return existing
resolution = metadata_json.get("resolution")
if isinstance(resolution, (list, tuple)) and len(resolution) >= 2:
return {"x": float(resolution[0]), "y": float(resolution[1])}
return None
@staticmethod
def _extract_raster_bands_json(metadata_json: dict[str, Any]) -> dict[str, Any] | None:
existing = metadata_json.get("bands_json")
if isinstance(existing, dict):
return existing
bands_json: dict[str, Any] = {}
if metadata_json.get("band_count") is not None:
bands_json["band_count"] = int(metadata_json["band_count"])
if metadata_json.get("dtype") is not None:
bands_json["dtype"] = metadata_json["dtype"]
return bands_json or None
@classmethod
def _find_existing_ingest(cls, db: Session, project_id: UUID, ingest_key: str) -> Dataset | None:
if not cls._registry_persistence_available(db):
return None
return SourceRegistryService.find_dataset_by_ingest_key(db, project_id, ingest_key)
@classmethod
def _record_snapshot(
cls,
db: Session,
*,
source_key: str,
checksum_sha256: str,
source_version: str | None,
observed_at: datetime | None,
valid_from: datetime | None,
valid_to: datetime | None,
source_crs: str | None,
source_metadata: dict[str, Any] | None,
metadata: dict[str, Any],
) -> tuple[Any | None, Any | None]:
if not cls._registry_persistence_available(db):
return None, None
source = SourceRegistryService.ensure_server_owned_source(db, source_key)
source_values = source_metadata if isinstance(source_metadata, dict) else {}
resolution = metadata.get("resolution_json") or metadata.get("resolution") or {}
if isinstance(resolution, (list, tuple)) and len(resolution) >= 2:
resolution = {"x": resolution[0], "y": resolution[1], "unit": cls._resolution_unit_for_crs(source_crs)}
if not isinstance(resolution, dict):
resolution = {"status": "unknown"}
snapshot_key = f"{source_key}:{source_version or 'unversioned'}:{checksum_sha256.lower()}"
snapshot = SourceRegistryService.record_snapshot(
db,
source_key=source_key,
snapshot_key=snapshot_key,
checksum_sha256=checksum_sha256,
source_version=source_version,
snapshot_at=observed_at,
fetched_at=datetime.now(timezone.utc),
reuse_existing_snapshot=True,
source_url=(
source_values.get("source_url")
or source_values.get("catalogue_url")
or source_values.get("service_url")
),
crs=source_crs,
units=source_values.get("units") or source.default_units,
spatial_resolution=resolution,
temporal_coverage={
"observed_at": observed_at.isoformat() if observed_at else None,
"valid_from": valid_from.isoformat() if valid_from else None,
"valid_to": valid_to.isoformat() if valid_to else None,
},
geographic_coverage={
"bbox": metadata.get("source_bounds_json") or metadata.get("bounds_json") or metadata.get("bounds"),
"coverage_zones": source_values.get("coverage_zones") or source_values.get("coverage_zone"),
},
observed_schema={
"dataset_type": metadata.get("dataset_type"),
"geometry_types": metadata.get("geometry_types"),
"bands": metadata.get("band_count"),
"attributes": source_values.get("expected_attributes"),
},
freshness_status=cls._snapshot_freshness_status(
source_key,
source_metadata,
observed_at=observed_at,
source_version=source_version,
),
ingest_status="ingested",
known_limitations=list(source_values.get("known_limitations") or []),
snapshot_metadata={
"source_metadata": source_values,
"source_checksum_sha256": checksum_sha256.lower(),
},
)
return source, snapshot
@classmethod
def _apply_validation_report(
cls,
db: Session,
*,
dataset: Dataset,
dataset_version: DatasetVersion,
report: ValidationReport,
source: Any | None,
snapshot: Any | None,
artifact_path: str | None,
) -> None:
fields = report.persistence_fields()
dataset.validation_report_json = fields["validation_report_json"]
dataset.quarantine_status = fields["quarantine_status"]
dataset_version.validation_report_json = fields["validation_report_json"]
if source is not None and snapshot is not None:
SourceRegistryService.bind_dataset_provenance(
dataset,
source=source,
snapshot=snapshot,
data_contract_key=fields["data_contract_key"],
data_contract_version=fields["data_contract_version"],
validation_status=fields["validation_status"],
provenance_status=fields["provenance_status"],
lineage_status=fields["lineage_status"],
)
SourceRegistryService.bind_dataset_version_provenance(
dataset_version,
source=source,
snapshot=snapshot,
data_contract_key=fields["data_contract_key"],
data_contract_version=fields["data_contract_version"],
validation_status=fields["validation_status"],
provenance_status=fields["provenance_status"],
lineage_status=fields["lineage_status"],
)
else:
for target in (dataset, dataset_version):
target.data_contract_key = fields["data_contract_key"]
target.data_contract_version = fields["data_contract_version"]
target.validation_status = fields["validation_status"]
target.provenance_status = fields["provenance_status"]
target.lineage_status = fields["lineage_status"]
decision = DataQuarantineService.decide(report)
if decision.eligible_for_use:
dataset.status = "ready"
dataset.quarantine_status = "not_quarantined"
return
dataset.status = "quarantined"
dataset.quarantine_status = "quarantined"
if source is not None and snapshot is not None:
SourceRegistryService.quarantine_dataset(
db,
dataset=dataset,
dataset_version=dataset_version,
source_snapshot=snapshot,
stage="ingest_validation",
reason_code=(decision.reason_codes[0] if decision.reason_codes else "DATA_CONTRACT_FAILED"),
details={"validation_report": report.to_dict(), "quarantine_decision": decision.to_dict()},
artifact_path=artifact_path,
artifact_checksum_sha256=dataset.checksum_sha256,
)
@classmethod
def _new_dataset_version(
cls,
dataset: Dataset,
*,
ingest_key: str | None,
) -> DatasetVersion:
return DatasetVersion(
id=uuid.uuid4(),
dataset_id=dataset.id,
version=1,
storage_path=dataset.storage_path,
source_version=dataset.source_version,
observed_at=dataset.observed_at,
valid_from=dataset.valid_from,
valid_to=dataset.valid_to,
checksum_sha256=dataset.checksum_sha256,
ingest_key=f"{ingest_key}:v1" if ingest_key else None,
source_metadata=dataset.source_metadata,
provenance_metadata=dataset.provenance_metadata,
)
@staticmethod
async def _upload_dataset_legacy(
db: Session,
project_id: UUID,
file: UploadFile,
dataset_type: str,
source: str,
dataset_role: str = "source",
source_name: str | None = None,
reference_layer_name: str | None = None,
source_metadata: dict | None = None,
provenance_metadata: dict | None = None,
area_id: UUID | None = None,
temporal_series_key: str | None = None,
observed_at: datetime | None = None,
valid_from: datetime | None = None,
valid_to: datetime | None = None,
temporal_granularity: str | None = None,
source_version: str | None = None,
) -> DatasetCreateResponse:
if not db.get(Project, project_id):
raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404)
filename = DatasetService._validate_upload_filename(file.filename)
canonical_type = DatasetService._canonical_dataset_type(dataset_type)
normalized_role = DatasetService._normalize_dataset_role(dataset_role)
temporal = DatasetService._validate_temporal_metadata(
temporal_series_key=temporal_series_key,
observed_at=observed_at,
valid_from=valid_from,
valid_to=valid_to,
temporal_granularity=temporal_granularity,
source_version=source_version,
)
normalized_source_name = source_name
if normalized_role == "reference" and not normalized_source_name:
normalized_source_name = "manual"
if normalized_role == "reference" and canonical_type == "raster":
raise AppError(code="INVALID_DATASET_ROLE", message="reference role requires vector dataset type", status_code=400)
extension = DatasetService._extension_for_path(filename)
if canonical_type == "vector" and extension not in DatasetService.VECTOR_EXTENSIONS:
raise AppError(code="INVALID_UPLOAD", message="Vector uploads require .geojson or .json files", status_code=415)
if canonical_type == "raster" and extension not in DatasetService.RASTER_EXTENSIONS:
raise AppError(
code="INVALID_UPLOAD",
message="Raster uploads require .tif, .tiff or .geotiff files",
status_code=415,
)
dataset_id = uuid.uuid4()
storage_info = await DatasetService._stage_upload(
project_id=project_id,
dataset_id=dataset_id,
dataset_type=canonical_type,
filename=filename,
file=file,
)
raw = DatasetService._read_staged_vector_bytes(storage_info) if canonical_type == "vector" else None
metadata: dict[str, Any] = {}
vector_payload: dict[str, Any] | None = None
status = "uploaded"
try:
status = "validating"
if canonical_type == "vector":
assert raw is not None
try:
text = raw.decode("utf-8")
except UnicodeDecodeError as exc:
raise AppError(code="INVALID_UPLOAD", message="Upload must be UTF-8 encoded", status_code=400) from exc
metadata = parse_geojson_payload(text)
vector_payload = json.loads(text)
status = "ready"
else:
metadata = extract_raster_metadata(storage_info["storage_path"])
status = "ready"
except ValueError as exc:
status = "failed"
StorageService.remove_dataset_file(storage_info["storage_path"])
raise AppError(code="INVALID_GEOJSON", message=str(exc), status_code=400) from exc
except AppError as exc:
if canonical_type == "raster" and exc.code == "RASTER_PROCESSING_UNAVAILABLE":
status = "failed"
metadata = {
"processing_error": exc.message,
"processing_code": exc.code,
}
else:
StorageService.remove_dataset_file(storage_info["storage_path"])
raise
bounds_json = metadata.get("bounds_json") if isinstance(metadata, dict) else None
resolution_json = metadata.get("resolution_json") if isinstance(metadata, dict) else None
bands_json = metadata.get("bands_json") if isinstance(metadata, dict) else None
if canonical_type == "raster" and isinstance(metadata, dict):
bounds_json = DatasetService._extract_raster_bounds_json(metadata)
resolution_json = DatasetService._extract_raster_resolution_json(metadata)
bands_json = DatasetService._extract_raster_bands_json(metadata)
dataset = Dataset(
id=dataset_id,
project_id=project_id,
area_id=area_id,
name=filename,
dataset_type=canonical_type,
source=source,
dataset_role=normalized_role,
source_name=normalized_source_name,
reference_layer_name=reference_layer_name if normalized_role == "reference" else None,
source_metadata=source_metadata,
provenance_metadata=provenance_metadata,
imported_at=datetime.now(timezone.utc),
**temporal,
storage_path=storage_info["storage_path"],
original_filename=storage_info["original_filename"],
stored_filename=storage_info["stored_filename"],
content_type=storage_info["content_type"],
size_bytes=storage_info["size_bytes"],
checksum_sha256=storage_info["checksum_sha256"],
crs=metadata.get("crs") if isinstance(metadata, dict) else None,
bounds_json=bounds_json,
resolution_json=resolution_json,
bands_json=bands_json,
metadata_json=metadata,
status=status,
)
try:
db.add(dataset)
db.add(
DatasetVersion(
dataset_id=dataset.id,
version=1,
storage_path=dataset.storage_path,
source_version=dataset.source_version,
observed_at=dataset.observed_at,
valid_from=dataset.valid_from,
valid_to=dataset.valid_to,
checksum_sha256=dataset.checksum_sha256,
source_metadata=dataset.source_metadata,
provenance_metadata=dataset.provenance_metadata,
)
)
if canonical_type == "vector" and vector_payload is not None and status == "ready":
feature_class = reference_layer_name if normalized_role == "reference" else None
VectorFeatureService.persist_geojson_features(
db=db,
dataset_id=dataset.id,
payload=vector_payload,
feature_class=feature_class,
commit=False,
)
db.commit()
db.refresh(dataset)
except Exception:
db.rollback()
StorageService.remove_dataset_file(storage_info["storage_path"])
raise
return DatasetService._to_response(dataset)
@staticmethod
async def upload_dataset(
db: Session,
project_id: UUID,
file: UploadFile,
dataset_type: str,
source: str,
dataset_role: str = "source",
source_name: str | None = None,
reference_layer_name: str | None = None,
source_metadata: dict | None = None,
provenance_metadata: dict | None = None,
area_id: UUID | None = None,
temporal_series_key: str | None = None,
observed_at: datetime | None = None,
valid_from: datetime | None = None,
valid_to: datetime | None = None,
temporal_granularity: str | None = None,
source_version: str | None = None,
) -> DatasetCreateResponse:
"""Stage a user upload as an explicitly manual, non-authoritative source.
Client text such as ``source_name=grb`` is retained only as a claim in
provenance. It cannot select an authoritative registry entry; only a
server-owned acquisition adapter reaches those entries.
"""
if not DatasetService._registry_persistence_available(db):
return await DatasetService._upload_dataset_legacy(
db=db,
project_id=project_id,
file=file,
dataset_type=dataset_type,
source=source,
dataset_role=dataset_role,
source_name=source_name,
reference_layer_name=reference_layer_name,
source_metadata=source_metadata,
provenance_metadata=provenance_metadata,
area_id=area_id,
temporal_series_key=temporal_series_key,
observed_at=observed_at,
valid_from=valid_from,
valid_to=valid_to,
temporal_granularity=temporal_granularity,
source_version=source_version,
)
if not db.get(Project, project_id):
raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404)
if area_id is not None:
area = db.get(Area, area_id)
if not area:
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)
filename = DatasetService._validate_upload_filename(file.filename)
canonical_type = DatasetService._canonical_dataset_type(dataset_type)
normalized_role = DatasetService._normalize_dataset_role(dataset_role)
if normalized_role == "reference" and canonical_type == "raster":
raise AppError(code="INVALID_DATASET_ROLE", message="reference role requires vector dataset type", status_code=400)
extension = DatasetService._extension_for_path(filename)
if canonical_type == "vector" and extension not in DatasetService.VECTOR_EXTENSIONS:
raise AppError(code="INVALID_UPLOAD", message="Vector uploads require .geojson or .json files", status_code=415)
if canonical_type == "raster" and extension not in DatasetService.RASTER_EXTENSIONS:
raise AppError(code="INVALID_UPLOAD", message="Raster uploads require .tif, .tiff or .geotiff files", status_code=415)
temporal = DatasetService._validate_temporal_metadata(
temporal_series_key=temporal_series_key,
observed_at=observed_at,
valid_from=valid_from,
valid_to=valid_to,
temporal_granularity=temporal_granularity,
source_version=source_version,
)
dataset_id = uuid.uuid4()
storage_info = await DatasetService._stage_upload(
project_id=project_id,
dataset_id=dataset_id,
dataset_type=canonical_type,
filename=filename,
file=file,
)
checksum_sha256 = storage_info["checksum_sha256"]
ingest_key = DatasetService._ingest_key(
project_id=project_id,
source_key="manual",
checksum_sha256=checksum_sha256,
dataset_type=canonical_type,
dataset_role=normalized_role,
area_id=area_id,
reference_layer_name=reference_layer_name if normalized_role == "reference" else None,
source_version=temporal["source_version"],
)
existing = DatasetService._find_existing_ingest(db, project_id, ingest_key)
if existing is not None:
StorageService.remove_dataset_file(storage_info["storage_path"])
return DatasetService._to_response(existing)
raw_source_metadata = dict(source_metadata or {})
raw_provenance_metadata = dict(provenance_metadata or {})
raw_source_metadata.update(
{
"ingest_origin": "manual_upload",
"claimed_source": source,
"claimed_source_name": source_name,
"authority_claim_accepted": False,
}
)
raw_source_metadata.setdefault(
"temporal_unknown_reason",
"The manual upload does not assert a precise source observation timestamp.",
)
raw_source_metadata.setdefault(
"source_version_unknown_reason",
"The manual upload has no server-attested source edition or snapshot version.",
)
raw_provenance_metadata.update(
{
"ingest_origin": "manual_upload",
"ingest_key": ingest_key,
"claimed_source": {"source": source, "source_name": source_name},
}
)
raw = DatasetService._read_staged_vector_bytes(storage_info) if canonical_type == "vector" else None
storage_content = raw
source_evidence: dict[str, Any] | None = None
imported_at = datetime.now(timezone.utc)
metadata: dict[str, Any] = {"dataset_type": canonical_type}
source_crs: str | None = None
canonical_vector_payload: dict[str, Any] | None = None
parser_error: tuple[str, str] | None = None
try:
if canonical_type == "vector":
assert raw is not None
try:
payload = json.loads(raw.decode("utf-8"))
except UnicodeDecodeError as exc:
raise AppError(code="INVALID_UPLOAD", message="Upload must be UTF-8 encoded", status_code=400) from exc
raw_metadata = parse_geojson_payload(payload)
source_crs = str(raw_metadata.get("crs") or "").strip() or None
canonical_vector_payload = VectorFeatureService.canonicalize_geojson_payload(
payload,
source_crs=source_crs or DatasetService.CANONICAL_VECTOR_CRS,
)
metadata = parse_geojson_payload(canonical_vector_payload)
metadata.update(
{
"dataset_type": "vector",
"source_crs": source_crs,
"source_bounds_json": raw_metadata.get("bounds_json"),
"source_crs_assumed": raw_metadata.get("crs_assumed", False),
"canonical_storage_crs": DatasetService.CANONICAL_VECTOR_CRS,
}
)
if DatasetService._vector_storage_requires_canonicalization(source_crs):
storage_content = DatasetService._canonical_vector_storage_bytes(canonical_vector_payload)
source_evidence = DatasetService._persist_vector_source_evidence_from_path(
project_id=project_id,
dataset_id=dataset_id,
original_filename=filename,
source_path=storage_info["storage_path"],
content_type=file.content_type,
)
storage_info = StorageService.persist_dataset_file(
project_id=str(project_id),
dataset_id=str(dataset_id),
dataset_type=canonical_type,
original_filename=filename,
content=storage_content,
content_type=file.content_type,
)
else:
metadata = extract_raster_metadata(storage_info["storage_path"])
metadata["dataset_type"] = "raster"
source_crs = metadata.get("crs")
except (ValueError, json.JSONDecodeError, AppError) as exc:
code = exc.code if isinstance(exc, AppError) else "INVALID_GEOJSON"
parser_error = (code, str(exc))
metadata = {
"dataset_type": canonical_type,
"processing_error": str(exc),
"processing_code": code,
}
computed_storage_checksum_sha256 = storage_info["checksum_sha256"]
if source_evidence is not None:
resolved_source_crs = source_crs or DatasetService.CANONICAL_VECTOR_CRS
DatasetService._record_vector_source_evidence(
source_metadata=raw_source_metadata,
provenance_metadata=raw_provenance_metadata,
source_crs=resolved_source_crs,
evidence=source_evidence,
canonical_checksum_sha256=computed_storage_checksum_sha256,
)
metadata.update(
{
"source_artifact_checksum_sha256": source_evidence["checksum_sha256"],
"canonical_artifact_checksum_sha256": computed_storage_checksum_sha256,
}
)
source_registry, source_snapshot = DatasetService._record_snapshot(
db,
source_key="manual",
checksum_sha256=storage_info["checksum_sha256"],
source_version=temporal["source_version"],
observed_at=temporal["observed_at"],
valid_from=temporal["valid_from"],
valid_to=temporal["valid_to"],
source_crs=source_crs,
source_metadata=raw_source_metadata,
metadata=metadata,
)
contract_metadata = DatasetService._contract_metadata(
metadata=metadata,
source_metadata=raw_source_metadata,
provenance_metadata=raw_provenance_metadata,
source=source_registry,
)
if parser_error is not None:
report = DatasetService._failed_validation_report(
asset_id=ingest_key,
dataset_type=canonical_type,
code=parser_error[0],
message=parser_error[1],
now=imported_at,
)
elif canonical_type == "vector":
source_crs = source_crs or DatasetService.CANONICAL_VECTOR_CRS
lineage = LineageEvidence()
if source_crs.upper() != DatasetService.CANONICAL_VECTOR_CRS:
lineage = LineageEvidence(
transformations=(
TransformationEvidence(
name="vector_crs_normalization",
version="1.0.0",
checksum_sha256=DatasetService._stable_hash(
{"source_crs": source_crs, "storage_crs": DatasetService.CANONICAL_VECTOR_CRS}
),
),
)
)
try:
contract_metadata["source_schema_validation"] = DatasetService._validate_vector_source_schema(
source_registry,
canonical_vector_payload or {"type": "FeatureCollection", "features": []},
)
report = validate_registered_asset(
build_vector_ingest_input(
asset_id=ingest_key,
source_crs=source_crs,
storage_crs=DatasetService.CANONICAL_VECTOR_CRS,
feature_collection=canonical_vector_payload or {"type": "FeatureCollection", "features": []},
checksum_sha256=storage_info["checksum_sha256"],
computed_checksum_sha256=computed_storage_checksum_sha256,
content=None,
source_registry_id=str(source_registry.id),
source_snapshot_id=str(source_snapshot.id),
imported_at=imported_at,
metadata=contract_metadata,
observed_at=temporal["observed_at"],
valid_from=temporal["valid_from"],
valid_to=temporal["valid_to"],
temporal_unknown_reason=raw_source_metadata["temporal_unknown_reason"],
source_version=temporal["source_version"],
source_version_unknown_reason=raw_source_metadata["source_version_unknown_reason"],
lineage=lineage,
)
)
except AppError as exc:
report = DatasetService._failed_validation_report(
asset_id=ingest_key,
dataset_type="vector",
code=exc.code,
message=exc.message,
now=imported_at,
category="source_schema",
)
else:
resolution_json = DatasetService._extract_raster_resolution_json(metadata)
resolution = (
{"x": resolution_json["x"], "y": resolution_json["y"], "unit": DatasetService._resolution_unit_for_crs(source_crs)}
if resolution_json
else None
)
report = validate_registered_asset(
build_raster_ingest_input(
asset_id=ingest_key,
source_crs=source_crs,
storage_crs=source_crs,
raster_profile=metadata,
bounds=DatasetService._extract_raster_bounds_json(metadata),
resolution=resolution,
checksum_sha256=storage_info["checksum_sha256"],
computed_checksum_sha256=storage_info["checksum_sha256"],
content=None,
source_registry_id=str(source_registry.id),
source_snapshot_id=str(source_snapshot.id),
imported_at=imported_at,
metadata=contract_metadata,
observed_at=temporal["observed_at"],
valid_from=temporal["valid_from"],
valid_to=temporal["valid_to"],
temporal_unknown_reason=raw_source_metadata["temporal_unknown_reason"],
source_version=temporal["source_version"],
source_version_unknown_reason=raw_source_metadata["source_version_unknown_reason"],
)
)
bounds_json = metadata.get("bounds_json")
resolution_json = metadata.get("resolution_json")
bands_json = metadata.get("bands_json")
if canonical_type == "raster":
bounds_json = DatasetService._extract_raster_bounds_json(metadata)
resolution_json = DatasetService._extract_raster_resolution_json(metadata)
bands_json = DatasetService._extract_raster_bands_json(metadata)
dataset = Dataset(
id=dataset_id,
project_id=project_id,
area_id=area_id,
name=filename,
dataset_type=canonical_type,
source="manual_upload",
dataset_role=normalized_role,
source_name="manual",
reference_layer_name=reference_layer_name if normalized_role == "reference" else None,
source_metadata=raw_source_metadata,
provenance_metadata=raw_provenance_metadata,
imported_at=imported_at,
ingest_key=ingest_key,
**temporal,
storage_path=storage_info["storage_path"],
original_filename=storage_info["original_filename"],
stored_filename=storage_info["stored_filename"],
content_type=storage_info["content_type"],
size_bytes=storage_info["size_bytes"],
checksum_sha256=storage_info["checksum_sha256"],
crs=(DatasetService.CANONICAL_VECTOR_CRS if canonical_type == "vector" else source_crs),
bounds_json=bounds_json,
resolution_json=resolution_json,
bands_json=bands_json,
metadata_json=contract_metadata,
status="validating",
)
dataset_version = DatasetService._new_dataset_version(dataset, ingest_key=ingest_key)
try:
db.add(dataset)
db.add(dataset_version)
db.flush()
DatasetService._apply_validation_report(
db,
dataset=dataset,
dataset_version=dataset_version,
report=report,
source=source_registry,
snapshot=source_snapshot,
artifact_path=storage_info["storage_path"],
)
if report.validation_status == ValidationStatus.PASSED and canonical_vector_payload is not None:
VectorFeatureService.persist_geojson_features(
db=db,
dataset_id=dataset.id,
payload=canonical_vector_payload,
feature_class=reference_layer_name if normalized_role == "reference" else None,
source_crs=DatasetService.CANONICAL_VECTOR_CRS,
commit=False,
)
db.commit()
db.refresh(dataset)
except Exception:
db.rollback()
# Keep the staged bytes. A transport/database failure must remain
# inspectable instead of silently deleting the only evidence.
raise
return DatasetService._to_response(dataset)
@staticmethod
def _import_vector_bytes_legacy(
db: Session,
*,
project_id: UUID,
filename: str,
content: bytes,
source: str,
source_name: str,
dataset_role: str,
reference_layer_name: str | None,
source_metadata: dict[str, Any],
provenance_metadata: dict[str, Any],
area_id: UUID | None = None,
temporal_series_key: str | None = None,
observed_at: datetime | None = None,
valid_from: datetime | None = None,
valid_to: datetime | None = None,
temporal_granularity: str | None = None,
source_version: str | None = None,
content_type: str = "application/geo+json",
) -> DatasetCreateResponse:
if not db.get(Project, project_id):
raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404)
if area_id is not None:
area = db.get(Area, area_id)
if not area:
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)
if not content:
raise AppError(code="INVALID_UPLOAD", message="Vector artifact is empty", status_code=400)
safe_filename = DatasetService._validate_upload_filename(filename)
if DatasetService._extension_for_path(safe_filename) not in DatasetService.VECTOR_EXTENSIONS:
raise AppError(code="INVALID_UPLOAD", message="Vector artifacts require .geojson or .json files", status_code=415)
normalized_role = DatasetService._normalize_dataset_role(dataset_role)
normalized_source_name = (source_name or "").strip() or ("manual" if normalized_role == "reference" else None)
temporal = DatasetService._validate_temporal_metadata(
temporal_series_key=temporal_series_key,
observed_at=observed_at,
valid_from=valid_from,
valid_to=valid_to,
temporal_granularity=temporal_granularity,
source_version=source_version,
)
try:
text = content.decode("utf-8")
except UnicodeDecodeError as exc:
raise AppError(code="INVALID_UPLOAD", message="Vector artifact must be UTF-8 encoded", status_code=400) from exc
try:
metadata = parse_geojson_payload(text)
vector_payload = json.loads(text)
except (ValueError, json.JSONDecodeError) as exc:
raise AppError(code="INVALID_GEOJSON", message=str(exc), status_code=400) from exc
dataset_id = uuid.uuid4()
storage_info = StorageService.persist_dataset_file(
project_id=str(project_id),
dataset_id=str(dataset_id),
dataset_type="vector",
original_filename=safe_filename,
content=content,
content_type=content_type,
)
dataset = Dataset(
id=dataset_id,
project_id=project_id,
area_id=area_id,
name=safe_filename,
dataset_type="vector",
source=source,
dataset_role=normalized_role,
source_name=normalized_source_name,
reference_layer_name=reference_layer_name if normalized_role == "reference" else None,
source_metadata=source_metadata,
provenance_metadata=provenance_metadata,
imported_at=datetime.now(timezone.utc),
**temporal,
storage_path=storage_info["storage_path"],
original_filename=storage_info["original_filename"],
stored_filename=storage_info["stored_filename"],
content_type=storage_info["content_type"],
size_bytes=storage_info["size_bytes"],
checksum_sha256=storage_info["checksum_sha256"],
crs=metadata.get("crs"),
bounds_json=metadata.get("bounds_json"),
metadata_json=metadata,
status="ready",
)
try:
db.add(dataset)
db.add(
DatasetVersion(
dataset_id=dataset.id,
version=1,
storage_path=dataset.storage_path,
source_version=dataset.source_version,
observed_at=dataset.observed_at,
valid_from=dataset.valid_from,
valid_to=dataset.valid_to,
checksum_sha256=dataset.checksum_sha256,
source_metadata=dataset.source_metadata,
provenance_metadata=dataset.provenance_metadata,
)
)
VectorFeatureService.persist_geojson_features(
db=db,
dataset_id=dataset.id,
payload=vector_payload,
feature_class=reference_layer_name if normalized_role == "reference" else None,
commit=False,
)
db.commit()
db.refresh(dataset)
except Exception:
db.rollback()
StorageService.remove_dataset_file(storage_info["storage_path"])
raise
return DatasetService._to_response(dataset)
@staticmethod
def _import_raster_bytes_legacy(
db: Session,
*,
project_id: UUID,
filename: str,
content: bytes,
source: str,
source_name: str,
source_metadata: dict[str, Any],
provenance_metadata: dict[str, Any],
area_id: UUID | None = None,
temporal_series_key: str | None = None,
observed_at: datetime | None = None,
valid_from: datetime | None = None,
valid_to: datetime | None = None,
temporal_granularity: str | None = None,
source_version: str | None = None,
content_type: str = "image/tiff",
) -> DatasetCreateResponse:
if not db.get(Project, project_id):
raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404)
if area_id is not None:
area = db.get(Area, area_id)
if not area:
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)
if not content:
raise AppError(code="INVALID_UPLOAD", message="Raster artifact is empty", status_code=400)
safe_filename = DatasetService._validate_upload_filename(filename)
if DatasetService._extension_for_path(safe_filename) not in DatasetService.RASTER_EXTENSIONS:
raise AppError(code="INVALID_UPLOAD", message="Raster artifacts require a GeoTIFF filename", status_code=415)
temporal = DatasetService._validate_temporal_metadata(
temporal_series_key=temporal_series_key,
observed_at=observed_at,
valid_from=valid_from,
valid_to=valid_to,
temporal_granularity=temporal_granularity,
source_version=source_version,
)
dataset_id = uuid.uuid4()
storage_info = StorageService.persist_dataset_file(
project_id=str(project_id),
dataset_id=str(dataset_id),
dataset_type="raster",
original_filename=safe_filename,
content=content,
content_type=content_type,
)
try:
metadata = extract_raster_metadata(storage_info["storage_path"])
dataset = Dataset(
id=dataset_id,
project_id=project_id,
area_id=area_id,
name=safe_filename,
dataset_type="raster",
source=source,
dataset_role="source",
source_name=source_name,
source_metadata=source_metadata,
provenance_metadata=provenance_metadata,
imported_at=datetime.now(timezone.utc),
**temporal,
storage_path=storage_info["storage_path"],
original_filename=storage_info["original_filename"],
stored_filename=storage_info["stored_filename"],
content_type=storage_info["content_type"],
size_bytes=storage_info["size_bytes"],
checksum_sha256=storage_info["checksum_sha256"],
crs=metadata.get("crs"),
bounds_json=DatasetService._extract_raster_bounds_json(metadata),
resolution_json=DatasetService._extract_raster_resolution_json(metadata),
bands_json=DatasetService._extract_raster_bands_json(metadata),
metadata_json=metadata,
status="ready",
)
db.add(dataset)
db.add(
DatasetVersion(
dataset_id=dataset.id,
version=1,
storage_path=dataset.storage_path,
source_version=dataset.source_version,
observed_at=dataset.observed_at,
valid_from=dataset.valid_from,
valid_to=dataset.valid_to,
checksum_sha256=dataset.checksum_sha256,
source_metadata=dataset.source_metadata,
provenance_metadata=dataset.provenance_metadata,
)
)
db.commit()
db.refresh(dataset)
return DatasetService._to_response(dataset)
except Exception:
db.rollback()
StorageService.remove_dataset_file(storage_info["storage_path"])
raise
@staticmethod
def _governed_import_bytes(
db: Session,
*,
project_id: UUID,
filename: str,
content: bytes,
dataset_type: str,
source: str,
source_name: str,
dataset_role: str,
reference_layer_name: str | None,
source_metadata: dict[str, Any] | None,
provenance_metadata: dict[str, Any] | None,
area_id: UUID | None,
temporal_series_key: str | None,
observed_at: datetime | None,
valid_from: datetime | None,
valid_to: datetime | None,
temporal_granularity: str | None,
source_version: str | None,
content_type: str,
) -> DatasetCreateResponse:
"""Persist an adapter-owned source through one governed ingestion path.
Acquisition adapters choose an entry from the server-owned registry;
they cannot create authority identities dynamically. The original
artifact is deliberately retained if parsing or validation fails so
the immutable checksum, source snapshot and quarantine record remain
reviewable.
"""
if not db.get(Project, project_id):
raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404)
if area_id is not None:
area = db.get(Area, area_id)
if not area:
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)
if not content:
raise AppError(code="INVALID_UPLOAD", message=f"{dataset_type.title()} artifact is empty", status_code=400)
canonical_type = DatasetService._canonical_dataset_type(dataset_type)
normalized_role = DatasetService._normalize_dataset_role(dataset_role)
if normalized_role == "reference" and canonical_type == "raster":
raise AppError(code="INVALID_DATASET_ROLE", message="reference role requires vector dataset type", status_code=400)
safe_filename = DatasetService._validate_upload_filename(filename)
extension = DatasetService._extension_for_path(safe_filename)
allowed_extensions = DatasetService.VECTOR_EXTENSIONS if canonical_type == "vector" else DatasetService.RASTER_EXTENSIONS
if extension not in allowed_extensions:
expected = ".geojson or .json" if canonical_type == "vector" else ".tif, .tiff or .geotiff"
raise AppError(code="INVALID_UPLOAD", message=f"{canonical_type.title()} artifacts require {expected} files", status_code=415)
source_key = SourceRegistryService.normalize_source_key(source_name)
# This lookup deliberately happens before writing the artifact. A
# typo in an internal adapter must not acquire an unregistered source
# identity or silently downgrade itself to a manual source.
SourceRegistryService.definition_for(source_key)
temporal = DatasetService._validate_temporal_metadata(
temporal_series_key=temporal_series_key,
observed_at=observed_at,
valid_from=valid_from,
valid_to=valid_to,
temporal_granularity=temporal_granularity,
source_version=source_version,
)
computed_checksum_sha256 = StorageService.calculate_checksum_sha256(content)
ingest_key = DatasetService._ingest_key(
project_id=project_id,
source_key=source_key,
checksum_sha256=computed_checksum_sha256,
dataset_type=canonical_type,
dataset_role=normalized_role,
area_id=area_id,
reference_layer_name=reference_layer_name if normalized_role == "reference" else None,
source_version=temporal["source_version"],
)
existing = DatasetService._find_existing_ingest(db, project_id, ingest_key)
if existing is not None:
return DatasetService._to_response(existing)
governed_source_metadata = dict(source_metadata or {})
governed_provenance_metadata = dict(provenance_metadata or {})
governed_source_metadata.update(
{
"ingest_origin": "governed_acquisition_adapter",
"source_registry_key": source_key,
"authority_claim_accepted": True,
}
)
governed_source_metadata.setdefault(
"temporal_unknown_reason",
"The governed source did not publish a precise observation timestamp for this snapshot.",
)
governed_source_metadata.setdefault(
"source_version_unknown_reason",
"The governed source did not publish a stable source edition; the immutable checksum identifies this snapshot.",
)
governed_provenance_metadata.update(
{
"ingest_origin": "governed_acquisition_adapter",
"source_registry_key": source_key,
"ingest_key": ingest_key,
}
)
dataset_id = uuid.uuid4()
storage_info: dict[str, Any] | None = None
storage_content = content
source_evidence: dict[str, Any] | None = None
imported_at = datetime.now(timezone.utc)
metadata: dict[str, Any] = {"dataset_type": canonical_type}
source_crs: str | None = None
canonical_vector_payload: dict[str, Any] | None = None
parser_error: tuple[str, str] | None = None
try:
if canonical_type == "vector":
try:
payload = json.loads(content.decode("utf-8"))
except UnicodeDecodeError as exc:
raise AppError(code="INVALID_UPLOAD", message="Vector artifact must be UTF-8 encoded", status_code=400) from exc
raw_metadata = parse_geojson_payload(payload)
source_crs = str(raw_metadata.get("crs") or "").strip() or None
canonical_vector_payload = VectorFeatureService.canonicalize_geojson_payload(
payload,
source_crs=source_crs or DatasetService.CANONICAL_VECTOR_CRS,
)
metadata = parse_geojson_payload(canonical_vector_payload)
metadata.update(
{
"dataset_type": "vector",
"source_crs": source_crs,
"source_bounds_json": raw_metadata.get("bounds_json"),
"source_crs_assumed": raw_metadata.get("crs_assumed", False),
"canonical_storage_crs": DatasetService.CANONICAL_VECTOR_CRS,
}
)
if DatasetService._vector_storage_requires_canonicalization(source_crs):
storage_content = DatasetService._canonical_vector_storage_bytes(canonical_vector_payload)
source_evidence = DatasetService._persist_vector_source_evidence(
project_id=project_id,
dataset_id=dataset_id,
original_filename=safe_filename,
content=content,
content_type=content_type,
)
else:
storage_info = StorageService.persist_dataset_file(
project_id=str(project_id),
dataset_id=str(dataset_id),
dataset_type=canonical_type,
original_filename=safe_filename,
content=content,
content_type=content_type,
)
metadata = extract_raster_metadata(storage_info["storage_path"])
metadata["dataset_type"] = "raster"
source_crs = str(metadata.get("crs") or "").strip() or None
except (ValueError, json.JSONDecodeError, AppError) as exc:
code = exc.code if isinstance(exc, AppError) else "INVALID_GEOJSON"
parser_error = (code, str(exc))
metadata = {"dataset_type": canonical_type, "processing_error": str(exc), "processing_code": code}
if storage_info is None:
storage_info = StorageService.persist_dataset_file(
project_id=str(project_id),
dataset_id=str(dataset_id),
dataset_type=canonical_type,
original_filename=safe_filename,
content=storage_content,
content_type=content_type,
)
computed_storage_checksum_sha256 = StorageService.calculate_checksum_sha256(storage_content)
if source_evidence is not None:
resolved_source_crs = source_crs or DatasetService.CANONICAL_VECTOR_CRS
DatasetService._record_vector_source_evidence(
source_metadata=governed_source_metadata,
provenance_metadata=governed_provenance_metadata,
source_crs=resolved_source_crs,
evidence=source_evidence,
canonical_checksum_sha256=computed_storage_checksum_sha256,
)
metadata.update(
{
"source_artifact_checksum_sha256": source_evidence["checksum_sha256"],
"canonical_artifact_checksum_sha256": computed_storage_checksum_sha256,
}
)
source_registry, source_snapshot = DatasetService._record_snapshot(
db,
source_key=source_key,
checksum_sha256=storage_info["checksum_sha256"],
source_version=temporal["source_version"],
observed_at=temporal["observed_at"],
valid_from=temporal["valid_from"],
valid_to=temporal["valid_to"],
source_crs=source_crs,
source_metadata=governed_source_metadata,
metadata=metadata,
)
contract_metadata = DatasetService._contract_metadata(
metadata=metadata,
source_metadata=governed_source_metadata,
provenance_metadata=governed_provenance_metadata,
source=source_registry,
)
if parser_error is not None:
report = DatasetService._failed_validation_report(
asset_id=ingest_key,
dataset_type=canonical_type,
code=parser_error[0],
message=parser_error[1],
now=imported_at,
)
elif canonical_type == "vector":
resolved_source_crs = source_crs or DatasetService.CANONICAL_VECTOR_CRS
lineage = LineageEvidence()
if resolved_source_crs.upper() != DatasetService.CANONICAL_VECTOR_CRS:
lineage = LineageEvidence(
transformations=(
TransformationEvidence(
name="vector_crs_normalization",
version="1.0.0",
checksum_sha256=DatasetService._stable_hash(
{"source_crs": resolved_source_crs, "storage_crs": DatasetService.CANONICAL_VECTOR_CRS}
),
),
)
)
try:
contract_metadata["source_schema_validation"] = DatasetService._validate_vector_source_schema(
source_registry,
canonical_vector_payload or {"type": "FeatureCollection", "features": []},
)
report = validate_registered_asset(
build_vector_ingest_input(
asset_id=ingest_key,
source_crs=resolved_source_crs,
storage_crs=DatasetService.CANONICAL_VECTOR_CRS,
feature_collection=canonical_vector_payload or {"type": "FeatureCollection", "features": []},
checksum_sha256=storage_info["checksum_sha256"],
computed_checksum_sha256=computed_storage_checksum_sha256,
content=storage_content,
source_registry_id=str(source_registry.id),
source_snapshot_id=str(source_snapshot.id),
imported_at=imported_at,
metadata=contract_metadata,
observed_at=temporal["observed_at"],
valid_from=temporal["valid_from"],
valid_to=temporal["valid_to"],
temporal_unknown_reason=governed_source_metadata["temporal_unknown_reason"],
source_version=temporal["source_version"],
source_version_unknown_reason=governed_source_metadata["source_version_unknown_reason"],
lineage=lineage,
)
)
except AppError as exc:
report = DatasetService._failed_validation_report(
asset_id=ingest_key,
dataset_type="vector",
code=exc.code,
message=exc.message,
now=imported_at,
category="source_schema",
)
else:
resolution_json = DatasetService._extract_raster_resolution_json(metadata)
resolution = (
{
"x": resolution_json["x"],
"y": resolution_json["y"],
"unit": DatasetService._resolution_unit_for_crs(source_crs),
}
if resolution_json
else None
)
report = validate_registered_asset(
build_raster_ingest_input(
asset_id=ingest_key,
source_crs=source_crs,
storage_crs=source_crs,
raster_profile=metadata,
bounds=DatasetService._extract_raster_bounds_json(metadata),
resolution=resolution,
checksum_sha256=storage_info["checksum_sha256"],
computed_checksum_sha256=computed_checksum_sha256,
content=content,
source_registry_id=str(source_registry.id),
source_snapshot_id=str(source_snapshot.id),
imported_at=imported_at,
metadata=contract_metadata,
observed_at=temporal["observed_at"],
valid_from=temporal["valid_from"],
valid_to=temporal["valid_to"],
temporal_unknown_reason=governed_source_metadata["temporal_unknown_reason"],
source_version=temporal["source_version"],
source_version_unknown_reason=governed_source_metadata["source_version_unknown_reason"],
)
)
bounds_json = metadata.get("bounds_json")
resolution_json = metadata.get("resolution_json")
bands_json = metadata.get("bands_json")
if canonical_type == "raster":
bounds_json = DatasetService._extract_raster_bounds_json(metadata)
resolution_json = DatasetService._extract_raster_resolution_json(metadata)
bands_json = DatasetService._extract_raster_bands_json(metadata)
dataset = Dataset(
id=dataset_id,
project_id=project_id,
area_id=area_id,
name=safe_filename,
dataset_type=canonical_type,
source=source,
dataset_role=normalized_role,
source_name=source_key,
reference_layer_name=reference_layer_name if normalized_role == "reference" else None,
source_metadata=governed_source_metadata,
provenance_metadata=governed_provenance_metadata,
imported_at=imported_at,
ingest_key=ingest_key,
**temporal,
storage_path=storage_info["storage_path"],
original_filename=storage_info["original_filename"],
stored_filename=storage_info["stored_filename"],
content_type=storage_info["content_type"],
size_bytes=storage_info["size_bytes"],
checksum_sha256=storage_info["checksum_sha256"],
crs=(DatasetService.CANONICAL_VECTOR_CRS if canonical_type == "vector" else source_crs),
bounds_json=bounds_json,
resolution_json=resolution_json,
bands_json=bands_json,
metadata_json=contract_metadata,
status="validating",
)
dataset_version = DatasetService._new_dataset_version(dataset, ingest_key=ingest_key)
try:
db.add(dataset)
db.add(dataset_version)
db.flush()
DatasetService._apply_validation_report(
db,
dataset=dataset,
dataset_version=dataset_version,
report=report,
source=source_registry,
snapshot=source_snapshot,
artifact_path=storage_info["storage_path"],
)
if report.validation_status == ValidationStatus.PASSED and canonical_vector_payload is not None:
VectorFeatureService.persist_geojson_features(
db=db,
dataset_id=dataset.id,
payload=canonical_vector_payload,
feature_class=reference_layer_name if normalized_role == "reference" else None,
source_crs=DatasetService.CANONICAL_VECTOR_CRS,
commit=False,
)
db.commit()
db.refresh(dataset)
except Exception:
db.rollback()
# Leave the staged artifact untouched. A failed persistence
# transaction is not evidence that the source bytes were safe to
# delete or that an acquisition can be repeated silently.
raise
return DatasetService._to_response(dataset)
@staticmethod
def import_vector_bytes(
db: Session,
*,
project_id: UUID,
filename: str,
content: bytes,
source: str,
source_name: str,
dataset_role: str,
reference_layer_name: str | None,
source_metadata: dict[str, Any],
provenance_metadata: dict[str, Any],
area_id: UUID | None = None,
temporal_series_key: str | None = None,
observed_at: datetime | None = None,
valid_from: datetime | None = None,
valid_to: datetime | None = None,
temporal_granularity: str | None = None,
source_version: str | None = None,
content_type: str = "application/geo+json",
) -> DatasetCreateResponse:
if not DatasetService._registry_persistence_available(db):
return DatasetService._import_vector_bytes_legacy(
db,
project_id=project_id,
filename=filename,
content=content,
source=source,
source_name=source_name,
dataset_role=dataset_role,
reference_layer_name=reference_layer_name,
source_metadata=source_metadata,
provenance_metadata=provenance_metadata,
area_id=area_id,
temporal_series_key=temporal_series_key,
observed_at=observed_at,
valid_from=valid_from,
valid_to=valid_to,
temporal_granularity=temporal_granularity,
source_version=source_version,
content_type=content_type,
)
return DatasetService._governed_import_bytes(
db,
project_id=project_id,
filename=filename,
content=content,
dataset_type="vector",
source=source,
source_name=source_name,
dataset_role=dataset_role,
reference_layer_name=reference_layer_name,
source_metadata=source_metadata,
provenance_metadata=provenance_metadata,
area_id=area_id,
temporal_series_key=temporal_series_key,
observed_at=observed_at,
valid_from=valid_from,
valid_to=valid_to,
temporal_granularity=temporal_granularity,
source_version=source_version,
content_type=content_type,
)
@staticmethod
def import_raster_bytes(
db: Session,
*,
project_id: UUID,
filename: str,
content: bytes,
source: str,
source_name: str,
source_metadata: dict[str, Any],
provenance_metadata: dict[str, Any],
area_id: UUID | None = None,
temporal_series_key: str | None = None,
observed_at: datetime | None = None,
valid_from: datetime | None = None,
valid_to: datetime | None = None,
temporal_granularity: str | None = None,
source_version: str | None = None,
content_type: str = "image/tiff",
) -> DatasetCreateResponse:
if not DatasetService._registry_persistence_available(db):
return DatasetService._import_raster_bytes_legacy(
db,
project_id=project_id,
filename=filename,
content=content,
source=source,
source_name=source_name,
source_metadata=source_metadata,
provenance_metadata=provenance_metadata,
area_id=area_id,
temporal_series_key=temporal_series_key,
observed_at=observed_at,
valid_from=valid_from,
valid_to=valid_to,
temporal_granularity=temporal_granularity,
source_version=source_version,
content_type=content_type,
)
return DatasetService._governed_import_bytes(
db,
project_id=project_id,
filename=filename,
content=content,
dataset_type="raster",
source=source,
source_name=source_name,
dataset_role="source",
reference_layer_name=None,
source_metadata=source_metadata,
provenance_metadata=provenance_metadata,
area_id=area_id,
temporal_series_key=temporal_series_key,
observed_at=observed_at,
valid_from=valid_from,
valid_to=valid_to,
temporal_granularity=temporal_granularity,
source_version=source_version,
content_type=content_type,
)
@staticmethod
def import_partitioned_vector_artifact(
db: Session,
*,
project_id: UUID,
area_id: UUID,
artifact_path: str | Path,
partition_paths: list[str | Path],
original_filename: str,
source: str,
dataset_role: str,
source_name: str,
reference_layer_name: str | None,
metadata_json: dict[str, Any],
source_metadata: dict[str, Any],
provenance_metadata: dict[str, Any],
temporal_series_key: str,
observed_at: datetime,
temporal_granularity: str = "snapshot",
source_version: str | None = None,
batch_size: int = 1000,
) -> DatasetCreateResponse:
if not DatasetService._registry_persistence_available(db):
raise AppError(
code="SOURCE_REGISTRY_PERSISTENCE_UNAVAILABLE",
message="Partitioned authoritative imports require registry and provenance persistence.",
status_code=503,
)
if not db.get(Project, project_id):
raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404)
area = db.get(Area, area_id)
if not area:
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)
if not partition_paths:
raise AppError(
code="INVALID_GEOJSON_PARTITIONS",
message="At least one GeoJSON partition is required",
status_code=400,
)
filename = DatasetService._validate_upload_filename(original_filename)
if DatasetService._extension_for_path(filename) not in DatasetService.VECTOR_EXTENSIONS:
raise AppError(code="INVALID_UPLOAD", message="Vector artifacts require .geojson or .json files", status_code=415)
normalized_role = DatasetService._normalize_dataset_role(dataset_role)
source_key = SourceRegistryService.normalize_source_key(source_name)
# A partitioned operator artifact is never allowed to manufacture a
# source identity from its caller-provided label.
SourceRegistryService.definition_for(source_key)
temporal = DatasetService._validate_temporal_metadata(
temporal_series_key=temporal_series_key,
observed_at=observed_at,
valid_from=observed_at,
valid_to=None,
temporal_granularity=temporal_granularity,
source_version=source_version,
)
metadata = dict(metadata_json)
expected_feature_count = int(metadata.get("feature_count") or 0)
if expected_feature_count <= 0:
raise AppError(
code="INVALID_GEOJSON_PARTITIONS",
message="Partition metadata must declare a positive feature_count",
status_code=400,
)
artifact_checksum_sha256 = DatasetService._calculate_file_checksum_sha256(artifact_path)
ingest_key = DatasetService._ingest_key(
project_id=project_id,
source_key=source_key,
checksum_sha256=artifact_checksum_sha256,
dataset_type="vector",
dataset_role=normalized_role,
area_id=area_id,
reference_layer_name=reference_layer_name if normalized_role == "reference" else None,
source_version=temporal["source_version"],
)
existing = DatasetService._find_existing_ingest(db, project_id, ingest_key)
if existing is not None:
return DatasetService._to_response(existing)
governed_source_metadata = dict(source_metadata or {})
governed_provenance_metadata = dict(provenance_metadata or {})
governed_source_metadata.update(
{
"ingest_origin": "governed_partitioned_acquisition_adapter",
"source_registry_key": source_key,
"authority_claim_accepted": True,
"partitioned_artifact": True,
}
)
governed_source_metadata.setdefault(
"temporal_unknown_reason",
"The governed source did not publish a precise observation timestamp for this snapshot.",
)
governed_source_metadata.setdefault(
"source_version_unknown_reason",
"The governed source did not publish a stable source edition; the immutable checksum identifies this snapshot.",
)
governed_provenance_metadata.update(
{
"ingest_origin": "governed_partitioned_acquisition_adapter",
"source_registry_key": source_key,
"ingest_key": ingest_key,
"artifact_checksum_sha256": artifact_checksum_sha256,
"combined_artifact_checksum_sha256": artifact_checksum_sha256,
"partition_count": len(partition_paths),
}
)
dataset_id = uuid.uuid4()
storage_info = StorageService.persist_dataset_file_from_path(
project_id=str(project_id),
dataset_id=str(dataset_id),
dataset_type="vector",
original_filename=filename,
source_path=artifact_path,
content_type="application/geo+json",
)
# The source file may have changed while it was copied. Re-key on the
# bytes actually retained; never bind a snapshot to a stale pre-copy
# checksum.
persisted_checksum_sha256 = str(storage_info["checksum_sha256"])
if persisted_checksum_sha256 != artifact_checksum_sha256:
ingest_key = DatasetService._ingest_key(
project_id=project_id,
source_key=source_key,
checksum_sha256=persisted_checksum_sha256,
dataset_type="vector",
dataset_role=normalized_role,
area_id=area_id,
reference_layer_name=reference_layer_name if normalized_role == "reference" else None,
source_version=temporal["source_version"],
)
existing = DatasetService._find_existing_ingest(db, project_id, ingest_key)
if existing is not None:
StorageService.remove_dataset_file(str(storage_info["storage_path"]))
return DatasetService._to_response(existing)
governed_provenance_metadata["ingest_key"] = ingest_key
governed_provenance_metadata["artifact_checksum_sha256"] = persisted_checksum_sha256
governed_provenance_metadata["combined_artifact_checksum_sha256"] = persisted_checksum_sha256
storage_crs = str(
metadata.get("canonical_storage_crs")
or metadata.get("storage_crs")
or metadata.get("crs")
or DatasetService.CANONICAL_VECTOR_CRS
).strip()
source_crs = str(metadata.get("source_crs") or storage_crs).strip() or None
metadata.update(
{
"dataset_type": "vector",
"canonical_storage_crs": DatasetService.CANONICAL_VECTOR_CRS,
"partitioned_artifact": True,
"partition_count": len(partition_paths),
}
)
source_registry, source_snapshot = DatasetService._record_snapshot(
db,
source_key=source_key,
checksum_sha256=persisted_checksum_sha256,
source_version=temporal["source_version"],
observed_at=temporal["observed_at"],
valid_from=temporal["valid_from"],
valid_to=temporal["valid_to"],
source_crs=source_crs,
source_metadata=governed_source_metadata,
metadata=metadata,
)
# Registry persistence is checked above, so absence here is an
# infrastructure fault rather than a state that can be imported.
if source_registry is None or source_snapshot is None: # pragma: no cover - defensive invariant
raise AppError(
code="SOURCE_REGISTRY_PERSISTENCE_UNAVAILABLE",
message="Source registry persistence did not return a governed snapshot.",
status_code=503,
)
contract_metadata = DatasetService._contract_metadata(
metadata=metadata,
source_metadata=governed_source_metadata,
provenance_metadata=governed_provenance_metadata,
source=source_registry,
)
partition_records = _PartitionedGeoJsonRecords(
partition_paths,
expected_feature_count=expected_feature_count,
declared_partition_checksums=governed_provenance_metadata.get("partition_checksums"),
source_schema=_SourceVectorSchema.from_source(source_registry),
)
lineage = LineageEvidence()
if source_crs and storage_crs.upper() != source_crs.upper():
lineage = LineageEvidence(
transformations=(
TransformationEvidence(
name="partitioned_vector_crs_normalization",
version="1.0.0",
checksum_sha256=DatasetService._stable_hash(
{
"source_crs": source_crs,
"storage_crs": storage_crs,
"partition_count": len(partition_paths),
}
),
),
)
)
declared_artifact_checksum = str(governed_provenance_metadata.get("artifact_sha256") or "").strip().lower()
artifact_binding_error: tuple[str, str] | None = None
if not declared_artifact_checksum:
artifact_binding_error = (
"ARTIFACT_CHECKSUM_REQUIRED",
"Partitioned ingestion requires the acquisition manifest's combined artifact checksum.",
)
elif not _CHECKSUM_SHA256.fullmatch(declared_artifact_checksum):
artifact_binding_error = (
"ARTIFACT_CHECKSUM_INVALID",
"Declared partitioned artifact checksum must be a lowercase SHA-256 value.",
)
elif declared_artifact_checksum != persisted_checksum_sha256:
artifact_binding_error = (
"ARTIFACT_CHECKSUM_MISMATCH",
"Declared artifact checksum does not match the retained partitioned artifact.",
)
try:
partition_audit = partition_records.audit()
contract_metadata["partitioned_geometry_audit"] = partition_audit.to_metadata()
contract_metadata["source_schema_validation"] = partition_audit.source_schema_validation
governed_provenance_metadata["partition_checksum_manifest_sha256"] = DatasetService._stable_hash(
partition_audit.partition_checksums_sha256
)
governed_provenance_metadata["partitioned_artifact_binding_sha256"] = DatasetService._stable_hash(
{
"combined_artifact_checksum_sha256": persisted_checksum_sha256,
"partition_checksum_manifest_sha256": governed_provenance_metadata[
"partition_checksum_manifest_sha256"
],
"feature_count": partition_audit.feature_count,
"storage_crs": storage_crs,
}
)
if artifact_binding_error is not None:
report = DatasetService._failed_validation_report(
asset_id=ingest_key,
dataset_type="vector",
code=artifact_binding_error[0],
message=artifact_binding_error[1],
now=datetime.now(timezone.utc),
category="checksum",
)
else:
report = validate_registered_asset(
DataAssetValidationInput(
asset_id=ingest_key,
data_contract_key=VECTOR_GEOJSON_CONTRACT_KEY,
data_contract_version=VECTOR_GEOJSON_CONTRACT_VERSION,
kind=ContractKind.VECTOR,
source_crs=source_crs,
storage_crs=storage_crs,
bounds=contract_metadata.get("bounds_json"),
checksum_sha256=persisted_checksum_sha256,
computed_checksum_sha256=persisted_checksum_sha256,
metadata=contract_metadata,
# The partition-bounded audit above validates every
# source feature. The generic contract receives only
# compact aggregate geometry evidence and therefore
# cannot materialize the complete regional artifact.
geometry_records=(partition_audit.representative_record,),
source_registry_id=str(source_registry.id),
source_snapshot_id=str(source_snapshot.id),
lineage=lineage,
imported_at=datetime.now(timezone.utc),
observed_at=temporal["observed_at"],
valid_from=temporal["valid_from"],
valid_to=temporal["valid_to"],
temporal_unknown_reason=governed_source_metadata["temporal_unknown_reason"],
source_version=temporal["source_version"],
source_version_unknown_reason=governed_source_metadata[
"source_version_unknown_reason"
],
)
)
except AppError as exc:
report = DatasetService._failed_validation_report(
asset_id=ingest_key,
dataset_type="vector",
code=exc.code,
message=exc.message,
now=datetime.now(timezone.utc),
category="source_schema" if exc.code.startswith("SOURCE_SCHEMA") else "parser",
)
dataset = Dataset(
id=dataset_id,
project_id=project_id,
area_id=area_id,
name=filename,
dataset_type="vector",
source=source,
dataset_role=normalized_role,
source_name=source_key,
reference_layer_name=reference_layer_name if normalized_role == "reference" else None,
source_metadata=governed_source_metadata,
provenance_metadata=governed_provenance_metadata,
imported_at=datetime.now(timezone.utc),
**temporal,
ingest_key=ingest_key,
storage_path=storage_info["storage_path"],
original_filename=storage_info["original_filename"],
stored_filename=storage_info["stored_filename"],
content_type=storage_info["content_type"],
size_bytes=storage_info["size_bytes"],
checksum_sha256=persisted_checksum_sha256,
crs=storage_crs,
bounds_json=metadata.get("bounds_json"),
metadata_json=contract_metadata,
status="validating",
)
dataset_version = DatasetService._new_dataset_version(dataset, ingest_key=ingest_key)
try:
db.add(dataset)
db.add(dataset_version)
db.flush()
if report.validation_status == ValidationStatus.PASSED:
try:
begin_nested = getattr(db, "begin_nested", None)
if callable(begin_nested):
with begin_nested():
persisted_count = VectorFeatureService.persist_geojson_partitions(
db,
dataset.id,
partition_paths,
feature_class=reference_layer_name if normalized_role == "reference" else None,
batch_size=batch_size,
source_crs=storage_crs,
)
if persisted_count != expected_feature_count:
raise AppError(
code="PARTITION_FEATURE_COUNT_MISMATCH",
message=(
f"Regional artifact declares {expected_feature_count} features but "
f"{persisted_count} queryable features were indexed"
),
status_code=400,
)
else: # lightweight test sessions only; production uses a savepoint
persisted_count = VectorFeatureService.persist_geojson_partitions(
db,
dataset.id,
partition_paths,
feature_class=reference_layer_name if normalized_role == "reference" else None,
batch_size=batch_size,
source_crs=storage_crs,
)
if persisted_count != expected_feature_count:
raise AppError(
code="PARTITION_FEATURE_COUNT_MISMATCH",
message=(
f"Regional artifact declares {expected_feature_count} features but "
f"{persisted_count} queryable features were indexed"
),
status_code=400,
)
except AppError as exc:
report = DatasetService._failed_validation_report(
asset_id=ingest_key,
dataset_type="vector",
code=exc.code,
message=exc.message,
now=datetime.now(timezone.utc),
)
DatasetService._apply_validation_report(
db,
dataset=dataset,
dataset_version=dataset_version,
report=report,
source=source_registry,
snapshot=source_snapshot,
artifact_path=str(storage_info["storage_path"]),
)
db.commit()
db.refresh(dataset)
except Exception:
db.rollback()
# Retain staged bytes for forensic review. A transaction error is
# not evidence that the source artifact may be safely discarded.
raise
return DatasetService._to_response(dataset)
@staticmethod
def refresh_metadata(db: Session, dataset_id: UUID) -> DatasetCreateResponse:
dataset = DatasetService._get_dataset(db, dataset_id)
if dataset.quarantine_status == "quarantined" or dataset.status == "quarantined":
raise AppError(
code="DATASET_QUARANTINED",
message="Quarantined datasets cannot be refreshed into an eligible state; re-ingest a new governed snapshot.",
status_code=409,
)
# A governed dataset's source snapshot and validation report bind the
# exact bytes, CRS and extracted metadata that were inspected at
# ingest. Re-reading a mutable storage path here would otherwise let
# an in-place replacement change the operational artifact while its
# persisted checksum/report still says ``passed``. Such a change must
# create a new immutable source snapshot through the governed ingest
# path; metadata refresh remains intentionally available only to rows
# without Phase-2 contract evidence.
has_governed_contract_evidence = any(
(
dataset.source_registry_id is not None,
dataset.source_snapshot_id is not None,
bool(str(dataset.data_contract_key or "").strip()),
bool(str(dataset.data_contract_version or "").strip()),
dataset.validation_report_json is not None,
dataset.validation_status == "passed",
)
)
if has_governed_contract_evidence:
raise AppError(
code="GOVERNED_DATASET_REINGEST_REQUIRED",
message=(
"Governed dataset metadata is immutable evidence. Re-ingest the artifact to create a new "
"source snapshot and validation report."
),
status_code=409,
)
if not dataset.storage_path:
raise AppError(code="DATASET_FILE_MISSING", message="Stored file missing", status_code=404)
if not Path(dataset.storage_path).exists():
raise AppError(code="DATASET_FILE_MISSING", message="Stored file missing", status_code=404)
try:
if DatasetService._is_vector_type(dataset.dataset_type):
metadata = parse_geojson_payload(load_dataset_text(dataset.storage_path))
elif DatasetService._is_raster_type(dataset.dataset_type):
metadata = extract_raster_metadata(dataset.storage_path)
else:
raise AppError(code="INVALID_DATASET_TYPE", message="Cannot refresh metadata for this dataset type", status_code=400)
except ValueError as exc:
raise AppError(code="INVALID_GEOJSON", message=str(exc), status_code=400) from exc
except AppError as exc:
if DatasetService._is_raster_type(dataset.dataset_type) and exc.code == "RASTER_PROCESSING_UNAVAILABLE":
metadata = {"processing_error": exc.message, "processing_code": exc.code}
else:
raise
bounds_json = metadata.get("bounds_json") if isinstance(metadata, dict) else dataset.bounds_json
resolution_json = metadata.get("resolution_json") if isinstance(metadata, dict) else dataset.resolution_json
bands_json = metadata.get("bands_json") if isinstance(metadata, dict) else dataset.bands_json
if DatasetService._is_raster_type(dataset.dataset_type) and isinstance(metadata, dict):
bounds_json = DatasetService._extract_raster_bounds_json(metadata)
resolution_json = DatasetService._extract_raster_resolution_json(metadata)
bands_json = DatasetService._extract_raster_bands_json(metadata)
# Metadata extraction is observational only. It must never turn an
# unvalidated historical row into a ready, authoritative dataset.
if DatasetService._is_vector_type(dataset.dataset_type):
dataset.crs = DatasetService.CANONICAL_VECTOR_CRS
else:
dataset.crs = metadata.get("crs") if isinstance(metadata, dict) else dataset.crs
dataset.bounds_json = bounds_json
existing_metadata = dataset.metadata_json if isinstance(dataset.metadata_json, dict) else {}
dataset.metadata_json = {**existing_metadata, **metadata}
dataset.resolution_json = resolution_json
dataset.bands_json = bands_json
db.add(dataset)
db.commit()
db.refresh(dataset)
return DatasetService._to_response(dataset)
@staticmethod
def update_temporal_metadata(db: Session, dataset_id: UUID, payload: DatasetTemporalUpdate) -> DatasetCreateResponse:
dataset = DatasetService._get_dataset(db, dataset_id)
if dataset.quarantine_status == "quarantined" or dataset.status == "quarantined":
raise AppError(
code="DATASET_QUARANTINED",
message="Quarantined datasets require a new governed ingest rather than an in-place temporal edit.",
status_code=409,
)
temporal = DatasetService._validate_temporal_metadata(**payload.model_dump())
if all(getattr(dataset, field) == value for field, value in temporal.items()):
return DatasetService._to_response(dataset)
for field, value in temporal.items():
setattr(dataset, field, value)
# Observation/source-version fields are contract inputs. Their edit
# invalidates the prior report, so later training/inference gates fail
# closed until a governed re-ingest persists a new snapshot/report.
dataset.status = "validating"
dataset.validation_status = "not_validated"
dataset.validation_report_json = None
dataset.provenance_status = "incomplete"
dataset.quarantine_status = "not_quarantined"
latest_version = (
db.query(DatasetVersion)
.filter(DatasetVersion.dataset_id == dataset.id)
.order_by(DatasetVersion.version.desc())
.first()
)
db.add(dataset)
db.add(
DatasetVersion(
dataset_id=dataset.id,
version=(latest_version.version + 1) if latest_version else 1,
storage_path=dataset.storage_path,
source_version=dataset.source_version,
observed_at=dataset.observed_at,
valid_from=dataset.valid_from,
valid_to=dataset.valid_to,
checksum_sha256=dataset.checksum_sha256,
ingest_key=(
f"{dataset.ingest_key}:temporal:{(latest_version.version + 1) if latest_version else 1}"
if dataset.ingest_key
else None
),
source_metadata=dataset.source_metadata,
provenance_metadata=dataset.provenance_metadata,
source_registry_id=dataset.source_registry_id,
source_snapshot_id=dataset.source_snapshot_id,
data_contract_key=dataset.data_contract_key,
data_contract_version=dataset.data_contract_version,
validation_status="not_validated",
validation_report_json=None,
provenance_status="incomplete",
lineage_status=dataset.lineage_status,
)
)
db.commit()
db.refresh(dataset)
return DatasetService._to_response(dataset)
@staticmethod
def list_versions(db: Session, dataset_id: UUID) -> list[DatasetVersionRead]:
DatasetService._get_dataset(db, dataset_id)
rows = (
db.query(DatasetVersion)
.filter(DatasetVersion.dataset_id == dataset_id)
.order_by(DatasetVersion.version.desc())
.all()
)
return [DatasetVersionRead.model_validate(row) for row in rows]
@staticmethod
def get_dataset(db: Session, dataset_id: UUID) -> Dataset:
dataset = db.get(Dataset, dataset_id)
if not dataset:
raise AppError(code="DATASET_NOT_FOUND", message="Dataset not found", status_code=404)
return dataset
@staticmethod
def _get_dataset(db: Session, dataset_id: UUID) -> Dataset:
return DatasetService.get_dataset(db, dataset_id)
@staticmethod
def get_dataset_geojson(db: Session, dataset_id: UUID) -> dict:
dataset = DatasetService._get_dataset(db, dataset_id)
if not DatasetService._is_vector_type(dataset.dataset_type):
raise AppError(code="INVALID_DATASET_TYPE", message="Dataset is not a vector dataset", status_code=400)
if not dataset.storage_path:
raise AppError(code="DATASET_FILE_MISSING", message="Stored file missing", status_code=404)
if not pathlib.Path(dataset.storage_path).exists():
raise AppError(code="DATASET_FILE_MISSING", message="Stored file missing", status_code=404)
raw = load_dataset_text(dataset.storage_path)
# Only the parse can be "not valid JSON". Everything after it fails for
# its own reasons and must say so, or an operator is sent to inspect a
# file that parses perfectly well.
try:
payload = json.loads(raw)
except Exception as exc:
raise AppError(
code="INVALID_GEOJSON",
message="Stored dataset is not valid JSON",
status_code=500,
) from exc
try:
metadata_value = getattr(dataset, "metadata_json", None)
metadata = metadata_value if isinstance(metadata_value, dict) else {}
provenance_value = getattr(dataset, "provenance_metadata", None)
provenance = provenance_value if isinstance(provenance_value, dict) else {}
canonical_evidence = provenance.get("canonical_consumption_artifact")
canonical_checksum = (
canonical_evidence.get("checksum_sha256")
if isinstance(canonical_evidence, dict)
else metadata.get("canonical_artifact_checksum_sha256")
)
# Post-normalization imports persist canonical bytes. Reapplying
# their original source CRS here would transform those coordinates
# a second time. Historical rows without this immutable binding
# retain the legacy read-time canonicalization behavior until they
# are re-ingested through the governed path.
source_crs = (
DatasetService.CANONICAL_VECTOR_CRS
if canonical_checksum == getattr(dataset, "checksum_sha256", None)
else (
metadata.get("source_crs")
or getattr(dataset, "crs", None)
or DatasetService.CANONICAL_VECTOR_CRS
)
)
return VectorFeatureService.canonicalize_geojson_payload(payload, source_crs=str(source_crs))
except AppError:
# The canonicaliser's diagnosis is more precise than anything this
# layer could substitute for it.
raise
except Exception as exc:
raise AppError(
code="DATASET_GEOJSON_UNREADABLE",
message="The stored dataset could not be read as canonical GeoJSON",
details={"dataset_id": str(dataset.id), "error_type": type(exc).__name__},
status_code=500,
) from exc
@staticmethod
def inspect_vector_dataset(db: Session, dataset_id: UUID) -> dict[str, Any]:
dataset = DatasetService._get_dataset(db, dataset_id)
if not DatasetService._is_vector_type(dataset.dataset_type):
raise AppError(code="INVALID_DATASET_TYPE", message="Dataset is not a vector dataset", status_code=400)
if not dataset.storage_path or not Path(dataset.storage_path).exists():
raise AppError(code="DATASET_FILE_MISSING", message="Stored file missing", status_code=404)
metadata = dataset.metadata_json or {}
if not isinstance(metadata, dict):
metadata = {}
summary = DatasetService._extract_vector_summary(dataset.dataset_type, metadata)
return {
"dataset": {
"id": str(dataset.id),
"name": dataset.name,
"dataset_type": dataset.dataset_type,
"status": dataset.status,
"source": dataset.source,
"storage": DatasetStorageResponse(
original_filename=dataset.original_filename,
stored_filename=dataset.stored_filename,
content_type=dataset.content_type,
size_bytes=dataset.size_bytes,
checksum_sha256=dataset.checksum_sha256,
).model_dump(),
"feature_count": metadata.get("feature_count"),
"crs": metadata.get("crs"),
},
"summary": summary.model_dump() if summary else None,
"metadata": metadata,
}
@staticmethod
def vector_summary(db: Session, dataset_id: UUID) -> dict[str, Any]:
dataset = DatasetService._get_dataset(db, dataset_id)
if not DatasetService._is_vector_type(dataset.dataset_type):
raise AppError(code="INVALID_DATASET_TYPE", message="Dataset is not a vector dataset", status_code=400)
metadata = dataset.metadata_json or {}
if not isinstance(metadata, dict):
metadata = {}
summary = DatasetService._extract_vector_summary(dataset.dataset_type, metadata)
if not summary:
raise AppError(code="INVALID_GEOJSON", message="Vector summary unavailable", status_code=422)
return summary.model_dump()
@staticmethod
def raster_metadata(db: Session, dataset_id: UUID) -> dict[str, Any]:
dataset = DatasetService._get_dataset(db, dataset_id)
if dataset.quarantine_status == "quarantined" or dataset.status == "quarantined":
raise AppError(
code="DATASET_QUARANTINED",
message="Quarantined datasets cannot be read as production-ready raster metadata.",
status_code=409,
)
if not DatasetService._is_raster_type(dataset.dataset_type):
raise AppError(code="INVALID_DATASET_TYPE", message="Dataset is not a raster dataset", status_code=400)
if not dataset.storage_path:
raise AppError(code="DATASET_FILE_MISSING", message="Stored file missing", status_code=404)
if not Path(dataset.storage_path).exists():
raise AppError(code="DATASET_FILE_MISSING", message="Stored file missing", status_code=404)
if isinstance(dataset.metadata_json, dict) and dataset.metadata_json.get("driver"):
return dataset.metadata_json
metadata = extract_raster_metadata(dataset.storage_path)
dataset.metadata_json = dict(dataset.metadata_json or {})
dataset.metadata_json.update(metadata)
db.add(dataset)
db.commit()
db.refresh(dataset)
return metadata