feat: add temporal Mol explorer
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-14 15:19:42 +02:00
parent 0ec1ab4970
commit c0943fe7d4
39 changed files with 3065 additions and 163 deletions
+184 -95
View File
@@ -12,8 +12,14 @@ from fastapi import UploadFile
from sqlalchemy.orm import Session
from app.core.errors import AppError
from app.models import Dataset, Project
from app.schemas.dataset import DatasetCreateResponse, DatasetStorageResponse, DatasetVectorSummary
from app.models import Dataset, DatasetVersion, Project
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.storage_service import StorageService
@@ -26,6 +32,105 @@ class DatasetService:
VECTOR_TYPES = {"vector", "geojson"}
RASTER_TYPES = {"raster", "tif", "tiff", "geotiff"}
VALID_DATASET_ROLES = {"source", "derived", "reference"}
VALID_TEMPORAL_GRANULARITIES = {"snapshot", "day", "month", "year", "period"}
@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,
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:
@@ -89,44 +194,7 @@ class DatasetService:
.limit(limit)
.all()
)
response_items = []
for row in rows:
feature_count = None
metadata_json = row.metadata_json or {}
vector_summary = DatasetService._extract_vector_summary(row.dataset_type, metadata_json)
if isinstance(metadata_json, dict):
feature_count = metadata_json.get("feature_count")
response_items.append(
DatasetCreateResponse(
id=row.id,
name=row.name,
dataset_type=row.dataset_type,
source=row.source,
dataset_role=row.dataset_role,
source_name=row.source_name,
reference_layer_name=row.reference_layer_name,
source_metadata=row.source_metadata,
provenance_metadata=row.provenance_metadata,
imported_at=row.imported_at,
project_id=row.project_id,
area_id=row.area_id,
storage_path=row.storage_path,
original_filename=row.original_filename,
stored_filename=row.stored_filename,
content_type=row.content_type,
size_bytes=row.size_bytes,
checksum_sha256=row.checksum_sha256,
crs=row.crs,
bounds_json=row.bounds_json,
metadata_json=row.metadata_json,
vector_summary=vector_summary,
status=row.status,
derived_from_dataset_id=row.derived_from_dataset_id,
created_at=row.created_at,
feature_count=feature_count,
)
)
return response_items, total
return [DatasetService._to_response(row) for row in rows], total
@staticmethod
def _extract_vector_summary(dataset_type: str, metadata_json: dict) -> DatasetVectorSummary | None:
@@ -195,6 +263,12 @@ class DatasetService:
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)
@@ -202,6 +276,14 @@ class DatasetService:
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"
@@ -280,6 +362,7 @@ class DatasetService:
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"],
@@ -294,6 +377,20 @@ class DatasetService:
status=status,
)
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)
@@ -306,34 +403,7 @@ class DatasetService:
feature_class=feature_class,
)
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,
imported_at=dataset.imported_at,
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,
derived_from_dataset_id=dataset.derived_from_dataset_id,
bounds_json=dataset.bounds_json,
metadata_json=dataset.metadata_json,
vector_summary=DatasetService._extract_vector_summary(dataset.dataset_type, dataset.metadata_json or {}),
status=dataset.status,
created_at=dataset.created_at,
feature_count=metadata.get("feature_count") if isinstance(metadata, dict) else None,
)
return DatasetService._to_response(dataset)
@staticmethod
def refresh_metadata(db: Session, dataset_id: UUID) -> DatasetCreateResponse:
@@ -380,34 +450,53 @@ class DatasetService:
db.commit()
db.refresh(dataset)
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,
imported_at=dataset.imported_at,
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,
derived_from_dataset_id=dataset.derived_from_dataset_id,
bounds_json=dataset.bounds_json,
metadata_json=dataset.metadata_json,
vector_summary=DatasetService._extract_vector_summary(dataset.dataset_type, dataset.metadata_json or {}),
status=dataset.status,
created_at=dataset.created_at,
feature_count=metadata.get("feature_count") if isinstance(metadata, dict) else None,
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)
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)
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,
source_metadata=dataset.source_metadata,
provenance_metadata=dataset.provenance_metadata,
)
)
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: