1054 lines
43 KiB
Python
1054 lines
43 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import uuid
|
|
from html import escape
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.core.errors import AppError
|
|
from app.models import AnalysisRun, Area, Dataset, Export, Project, QualityCheck
|
|
from app.schemas.dhmv import TerrainPartitionSelectionRequest, TerrainSelectionRequest
|
|
from app.schemas.export import (
|
|
ExportContentResponse,
|
|
ExportCreateResponse,
|
|
ExportListResponse,
|
|
ExportRead,
|
|
MapResultExportRequest,
|
|
)
|
|
from app.schemas.flood_hazard import FloodHazardPartitionSelectionRequest, FloodHazardSelectionRequest
|
|
from app.schemas.temporal import TemporalComparisonRequest
|
|
from app.schemas.thematic_raster import ThematicRasterSelectionRequest
|
|
from app.services.dataset_service import DatasetService
|
|
from app.services.detection_service import DetectionService
|
|
from app.services.flood_hazard_analysis_service import FloodHazardAnalysisService
|
|
from app.services.segmentation_service import SegmentationService
|
|
from app.services.storage_service import StorageService
|
|
from app.services.temporal_analysis_service import TemporalAnalysisService
|
|
from app.services.terrain_analysis_service import TerrainAnalysisService
|
|
from app.services.thematic_raster_analysis_service import ThematicRasterAnalysisService
|
|
from app.services.vector_feature_service import VectorFeatureService
|
|
|
|
|
|
class ExportService:
|
|
@staticmethod
|
|
def export_map_result(
|
|
db: Session,
|
|
payload: MapResultExportRequest,
|
|
) -> ExportCreateResponse:
|
|
if payload.mode == "evolution":
|
|
comparison = TemporalAnalysisService.compare(
|
|
db,
|
|
project_id=payload.project_id,
|
|
payload=TemporalComparisonRequest(
|
|
earlier_dataset_id=payload.earlier_dataset_id,
|
|
later_dataset_id=payload.later_dataset_id,
|
|
bbox=payload.bbox,
|
|
area_id=payload.area_id,
|
|
),
|
|
)
|
|
content = comparison.model_dump(mode="json")
|
|
target_id = str(payload.later_dataset_id)
|
|
filename = ExportService._filename(
|
|
payload.name,
|
|
f"{target_id}-evolution.json",
|
|
".json",
|
|
)
|
|
export_path = StorageService.dataset_export_path(
|
|
str(payload.project_id),
|
|
target_id,
|
|
filename,
|
|
)
|
|
metadata = {
|
|
"source": "map_evolution",
|
|
"project_id": str(payload.project_id),
|
|
"earlier_dataset_id": str(payload.earlier_dataset_id),
|
|
"later_dataset_id": str(payload.later_dataset_id),
|
|
"selection_bbox": payload.bbox.model_dump(mode="json"),
|
|
"selection_area_id": str(payload.area_id) if payload.area_id else None,
|
|
"theme_id": payload.theme_id,
|
|
"server_recomputed": True,
|
|
}
|
|
export = ExportService._write_json_export(
|
|
db,
|
|
project_id=payload.project_id,
|
|
analysis_run_id=None,
|
|
export_type="map_evolution_json",
|
|
storage_path=export_path,
|
|
content=content,
|
|
metadata=metadata,
|
|
)
|
|
return ExportService._create_response(export)
|
|
|
|
dataset = db.get(Dataset, payload.dataset_id)
|
|
if not dataset or dataset.project_id != payload.project_id:
|
|
raise AppError(code="DATASET_NOT_FOUND", message="Dataset not found", status_code=404)
|
|
if dataset.dataset_type in DatasetService.VECTOR_TYPES:
|
|
if payload.partitioned:
|
|
return ExportService.export_partitioned_vector_selection_geojson(
|
|
db,
|
|
dataset,
|
|
payload.bbox.model_dump(mode="json"),
|
|
partition_scope_key=payload.partition_scope_key or "",
|
|
area_id=payload.area_id,
|
|
name=payload.name,
|
|
limit=1000,
|
|
)
|
|
return ExportService.export_vector_selection_geojson(
|
|
db,
|
|
dataset.id,
|
|
payload.bbox.model_dump(mode="json"),
|
|
area_id=payload.area_id,
|
|
name=payload.name,
|
|
limit=1000,
|
|
)
|
|
if dataset.dataset_type != "raster":
|
|
raise AppError(
|
|
code="INVALID_DATASET_TYPE",
|
|
message="Map-result export requires a vector or governed raster dataset",
|
|
details={"dataset_type": dataset.dataset_type},
|
|
status_code=400,
|
|
)
|
|
|
|
if dataset.source_name == "digitaal_vlaanderen_dhmv":
|
|
result = (
|
|
TerrainAnalysisService.analyze_partitions(
|
|
db,
|
|
payload.project_id,
|
|
TerrainPartitionSelectionRequest(
|
|
bbox=payload.bbox,
|
|
area_id=payload.area_id,
|
|
product_key=payload.product_key or "dtm_1m",
|
|
),
|
|
)
|
|
if payload.partitioned
|
|
else TerrainAnalysisService.analyze(
|
|
db,
|
|
payload.project_id,
|
|
dataset.id,
|
|
TerrainSelectionRequest(bbox=payload.bbox, area_id=payload.area_id),
|
|
)
|
|
)
|
|
elif dataset.source_name == "vmm_flood_hazard":
|
|
result = (
|
|
FloodHazardAnalysisService.analyze_partitions(
|
|
db,
|
|
payload.project_id,
|
|
FloodHazardPartitionSelectionRequest(
|
|
bbox=payload.bbox,
|
|
area_id=payload.area_id,
|
|
product_key=payload.product_key or "pluviaal_current_t100",
|
|
),
|
|
)
|
|
if payload.partitioned
|
|
else FloodHazardAnalysisService.analyze(
|
|
db,
|
|
payload.project_id,
|
|
dataset.id,
|
|
FloodHazardSelectionRequest(bbox=payload.bbox, area_id=payload.area_id),
|
|
)
|
|
)
|
|
elif dataset.source_name == "department_omgeving_thematic_raster":
|
|
result = ThematicRasterAnalysisService.analyze(
|
|
db,
|
|
payload.project_id,
|
|
dataset.id,
|
|
ThematicRasterSelectionRequest(bbox=payload.bbox, area_id=payload.area_id),
|
|
)
|
|
else:
|
|
raise AppError(
|
|
code="MAP_RESULT_EXPORT_UNSUPPORTED",
|
|
message="This raster source does not expose a governed map-result export",
|
|
details={"source_name": dataset.source_name},
|
|
status_code=400,
|
|
)
|
|
|
|
content = {
|
|
"mode": "current",
|
|
"theme_id": payload.theme_id,
|
|
"dataset": {
|
|
"id": str(dataset.id),
|
|
"name": dataset.name,
|
|
"source_name": dataset.source_name,
|
|
},
|
|
"result": result,
|
|
}
|
|
filename = ExportService._filename(
|
|
payload.name,
|
|
f"{dataset.id}-map-analysis.json",
|
|
".json",
|
|
)
|
|
export_path = StorageService.dataset_export_path(
|
|
str(payload.project_id),
|
|
str(dataset.id),
|
|
filename,
|
|
)
|
|
metadata = {
|
|
"source": "map_analysis",
|
|
"project_id": str(payload.project_id),
|
|
"dataset_id": str(dataset.id),
|
|
"selection_bbox": payload.bbox.model_dump(mode="json"),
|
|
"selection_area_id": str(payload.area_id) if payload.area_id else None,
|
|
"theme_id": payload.theme_id,
|
|
"partitioned": payload.partitioned,
|
|
"product_key": payload.product_key,
|
|
"server_recomputed": True,
|
|
}
|
|
export = ExportService._write_json_export(
|
|
db,
|
|
project_id=payload.project_id,
|
|
analysis_run_id=None,
|
|
export_type="map_analysis_json",
|
|
storage_path=export_path,
|
|
content=content,
|
|
metadata=metadata,
|
|
)
|
|
return ExportService._create_response(export)
|
|
|
|
@staticmethod
|
|
def export_partitioned_vector_selection_geojson(
|
|
db: Session,
|
|
dataset: Dataset,
|
|
bbox: dict[str, Any],
|
|
*,
|
|
partition_scope_key: str,
|
|
area_id: uuid.UUID | None = None,
|
|
limit: int = 1000,
|
|
name: str | None = None,
|
|
) -> ExportCreateResponse:
|
|
if dataset.source_name != "vmm_vha_bathymetry_profiles" or partition_scope_key != "flanders":
|
|
raise AppError(
|
|
code="PARTITIONED_VECTOR_EXPORT_UNSUPPORTED",
|
|
message="This vector source does not expose a governed partitioned export",
|
|
details={
|
|
"source_name": dataset.source_name,
|
|
"partition_scope_key": partition_scope_key,
|
|
},
|
|
status_code=400,
|
|
)
|
|
|
|
selection_geometry = None
|
|
partition_area_id = None
|
|
if area_id is not None:
|
|
area = db.get(Area, area_id)
|
|
if not area or area.project_id != dataset.project_id:
|
|
raise AppError(code="AREA_NOT_FOUND", message="Area not found", status_code=404)
|
|
selection_geometry, _covers_full_area = VectorFeatureService.constrain_bbox_to_area(
|
|
bbox,
|
|
area.geometry,
|
|
)
|
|
if str(area.name or "").lower().startswith("gemeente "):
|
|
partition_area_id = area.id
|
|
|
|
selection = VectorFeatureService.select_partitioned_features_by_bbox(
|
|
db,
|
|
project_id=dataset.project_id,
|
|
source_name=dataset.source_name,
|
|
partition_scope_key=partition_scope_key,
|
|
bbox=bbox,
|
|
limit=limit,
|
|
selection_geometry=selection_geometry,
|
|
selection_area_id=area_id,
|
|
partition_area_id=partition_area_id,
|
|
)
|
|
filename = ExportService._filename(name, "bathymetry-profile-selection.geojson", ".geojson")
|
|
export_path = StorageService.dataset_export_path(
|
|
str(dataset.project_id),
|
|
str(dataset.id),
|
|
filename,
|
|
)
|
|
metadata = {
|
|
"source": "partitioned_vector_selection",
|
|
"project_id": str(dataset.project_id),
|
|
"representative_dataset_id": str(dataset.id),
|
|
"dataset_ids": [str(value) for value in selection["dataset_ids"]],
|
|
"source_name": dataset.source_name,
|
|
"partition_scope_key": partition_scope_key,
|
|
"partition_count": selection["partition_count"],
|
|
"available_partition_count": selection["available_partition_count"],
|
|
"selection_bbox": selection["selection_bbox"],
|
|
"selection_area_id": selection.get("selection_area_id"),
|
|
"feature_count": selection["feature_count"],
|
|
"total_feature_count": selection["total_feature_count"],
|
|
"limit": selection["limit"],
|
|
"truncated": selection["truncated"],
|
|
"source_table": "vector_features",
|
|
"server_recomputed": True,
|
|
}
|
|
export = ExportService._write_json_export(
|
|
db,
|
|
project_id=dataset.project_id,
|
|
analysis_run_id=None,
|
|
export_type="partitioned_vector_selection_geojson",
|
|
storage_path=export_path,
|
|
content=selection["geojson"],
|
|
metadata=metadata,
|
|
)
|
|
return ExportService._create_response(export)
|
|
|
|
@staticmethod
|
|
def export_vector_selection_geojson(
|
|
db: Session,
|
|
dataset_id: uuid.UUID,
|
|
bbox: dict[str, Any],
|
|
area_id: uuid.UUID | None = None,
|
|
limit: int = 250,
|
|
name: str | None = None,
|
|
) -> ExportCreateResponse:
|
|
dataset = db.get(Dataset, dataset_id)
|
|
if not dataset:
|
|
raise AppError(code="DATASET_NOT_FOUND", message="Dataset not found", status_code=404)
|
|
if dataset.dataset_type not in DatasetService.VECTOR_TYPES:
|
|
raise AppError(
|
|
code="INVALID_DATASET_TYPE",
|
|
message="Vector selection export requires a vector dataset",
|
|
details={"dataset_type": dataset.dataset_type},
|
|
status_code=400,
|
|
)
|
|
|
|
selection_kwargs: dict[str, Any] = {
|
|
"dataset_id": dataset_id,
|
|
"bbox": bbox,
|
|
"limit": limit,
|
|
}
|
|
if area_id is not None:
|
|
area = db.get(Area, area_id)
|
|
if not area or area.project_id != dataset.project_id:
|
|
raise AppError(code="AREA_NOT_FOUND", message="Area not found", status_code=404)
|
|
selection_geometry, covers_full_area = VectorFeatureService.constrain_bbox_to_area(
|
|
bbox,
|
|
area.geometry,
|
|
)
|
|
full_dataset_area = covers_full_area and VectorFeatureService.can_use_full_area_fast_path(
|
|
dataset,
|
|
area.id,
|
|
)
|
|
preclipped_partition_filter = VectorFeatureService.preclipped_partition_filter(
|
|
dataset,
|
|
getattr(area, "name", None),
|
|
)
|
|
selection_kwargs.update(
|
|
selection_geometry=selection_geometry,
|
|
selection_area_id=area.id,
|
|
full_dataset_area=full_dataset_area,
|
|
preclipped_partition_filter=preclipped_partition_filter,
|
|
)
|
|
selection = VectorFeatureService.select_features_by_bbox(db, **selection_kwargs)
|
|
filename = ExportService._filename(name, f"{dataset.id}-selection.geojson", ".geojson")
|
|
export_path = StorageService.dataset_export_path(str(dataset.project_id), str(dataset.id), filename)
|
|
metadata = {
|
|
"source": "vector_selection",
|
|
"project_id": str(dataset.project_id),
|
|
"dataset_id": str(dataset.id),
|
|
"dataset_type": dataset.dataset_type,
|
|
"selection_bbox": selection["selection_bbox"],
|
|
"selection_area_id": selection.get("selection_area_id"),
|
|
"feature_count": selection["feature_count"],
|
|
"limit": selection["limit"],
|
|
"truncated": selection["truncated"],
|
|
"source_table": "vector_features",
|
|
}
|
|
export = ExportService._write_json_export(
|
|
db,
|
|
project_id=dataset.project_id,
|
|
analysis_run_id=None,
|
|
export_type="vector_selection_geojson",
|
|
storage_path=export_path,
|
|
content=selection["geojson"],
|
|
metadata=metadata,
|
|
)
|
|
return ExportService._create_response(export)
|
|
|
|
@staticmethod
|
|
def export_dataset_geojson(db: Session, dataset_id: uuid.UUID, name: str | None = None) -> ExportCreateResponse:
|
|
dataset = db.get(Dataset, dataset_id)
|
|
if not dataset:
|
|
raise AppError(code="DATASET_NOT_FOUND", message="Dataset not found", status_code=404)
|
|
if dataset.dataset_type not in DatasetService.VECTOR_TYPES:
|
|
raise AppError(
|
|
code="INVALID_DATASET_TYPE",
|
|
message="GeoJSON dataset export requires a vector dataset",
|
|
details={"dataset_type": dataset.dataset_type},
|
|
status_code=400,
|
|
)
|
|
|
|
feature_collection = DatasetService.get_dataset_geojson(db, dataset_id)
|
|
filename = ExportService._filename(name, f"{dataset.id}.geojson", ".geojson")
|
|
export_path = StorageService.dataset_export_path(str(dataset.project_id), str(dataset.id), filename)
|
|
metadata = {
|
|
"source": "dataset",
|
|
"dataset_id": str(dataset.id),
|
|
"project_id": str(dataset.project_id),
|
|
"dataset_type": dataset.dataset_type,
|
|
"feature_count": len(feature_collection.get("features", [])),
|
|
}
|
|
export = ExportService._write_json_export(
|
|
db,
|
|
project_id=dataset.project_id,
|
|
analysis_run_id=None,
|
|
export_type="dataset_geojson",
|
|
storage_path=export_path,
|
|
content=feature_collection,
|
|
metadata=metadata,
|
|
)
|
|
return ExportService._create_response(export)
|
|
|
|
@staticmethod
|
|
def export_detection_run_geojson(db: Session, analysis_run_id: uuid.UUID, name: str | None = None) -> ExportCreateResponse:
|
|
run = db.get(AnalysisRun, analysis_run_id)
|
|
if not run or run.analysis_type != "detection":
|
|
raise AppError(code="DETECTION_RUN_NOT_FOUND", message="Detection run not found", status_code=404)
|
|
|
|
feature_collection = DetectionService.detections_to_geojson(db, analysis_run_id=analysis_run_id)
|
|
filename = ExportService._filename(name, f"{run.id}-detections.geojson", ".geojson")
|
|
export_path = StorageService.dataset_export_path(str(run.project_id), str(run.dataset_id or run.id), filename)
|
|
metadata = {
|
|
"source": "detection_run",
|
|
"analysis_run_id": str(run.id),
|
|
"project_id": str(run.project_id),
|
|
"dataset_id": str(run.dataset_id) if run.dataset_id else None,
|
|
"feature_count": len(feature_collection.get("features", [])),
|
|
}
|
|
export = ExportService._write_json_export(
|
|
db,
|
|
project_id=run.project_id,
|
|
analysis_run_id=run.id,
|
|
export_type="detection_geojson",
|
|
storage_path=export_path,
|
|
content=feature_collection,
|
|
metadata=metadata,
|
|
)
|
|
return ExportService._create_response(export)
|
|
|
|
@staticmethod
|
|
def export_segmentation_run_geojson(db: Session, analysis_run_id: uuid.UUID, name: str | None = None) -> ExportCreateResponse:
|
|
run = db.get(AnalysisRun, analysis_run_id)
|
|
if not run or run.analysis_type != "segmentation":
|
|
raise AppError(code="SEGMENTATION_RUN_NOT_FOUND", message="Segmentation run not found", status_code=404)
|
|
|
|
feature_collection = SegmentationService.segmentations_to_geojson(db, analysis_run_id=analysis_run_id)
|
|
filename = ExportService._filename(name, f"{run.id}-segmentations.geojson", ".geojson")
|
|
export_path = StorageService.dataset_export_path(str(run.project_id), str(run.dataset_id or run.id), filename)
|
|
metadata = {
|
|
"source": "segmentation_run",
|
|
"analysis_run_id": str(run.id),
|
|
"project_id": str(run.project_id),
|
|
"dataset_id": str(run.dataset_id) if run.dataset_id else None,
|
|
"feature_count": len(feature_collection.get("features", [])),
|
|
}
|
|
export = ExportService._write_json_export(
|
|
db,
|
|
project_id=run.project_id,
|
|
analysis_run_id=run.id,
|
|
export_type="segmentation_geojson",
|
|
storage_path=export_path,
|
|
content=feature_collection,
|
|
metadata=metadata,
|
|
)
|
|
return ExportService._create_response(export)
|
|
|
|
@staticmethod
|
|
def export_project_metadata(db: Session, project_id: uuid.UUID, name: str | None = None) -> ExportCreateResponse:
|
|
project = db.get(Project, project_id)
|
|
if not project:
|
|
raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404)
|
|
|
|
content = ExportService._project_summary(db, project)
|
|
filename = ExportService._filename(name, f"{project.id}-metadata.json", ".json")
|
|
export_path = StorageService.dataset_export_path(str(project.id), "project", filename)
|
|
metadata = {
|
|
"source": "project_metadata",
|
|
"project_id": str(project.id),
|
|
"dataset_count": len(content["datasets"]),
|
|
"quality_check_count": len(content["quality_checks"]),
|
|
"export_count": len(content["exports"]),
|
|
"readiness_state": content["readiness_summary"]["overall_state"],
|
|
}
|
|
export = ExportService._write_json_export(
|
|
db,
|
|
project_id=project.id,
|
|
analysis_run_id=None,
|
|
export_type="project_metadata_json",
|
|
storage_path=export_path,
|
|
content=content,
|
|
metadata=metadata,
|
|
)
|
|
return ExportService._create_response(export)
|
|
|
|
@staticmethod
|
|
def export_project_report(db: Session, project_id: uuid.UUID, name: str | None = None) -> ExportCreateResponse:
|
|
project = db.get(Project, project_id)
|
|
if not project:
|
|
raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404)
|
|
|
|
summary = ExportService._project_summary(db, project)
|
|
html = ExportService._render_project_report_html(summary)
|
|
filename = ExportService._filename(name, f"{project.id}-report.html", ".html")
|
|
export_path = StorageService.dataset_export_path(str(project.id), "project", filename)
|
|
metadata = {
|
|
"source": "project_report",
|
|
"project_id": str(project.id),
|
|
"dataset_count": len(summary["datasets"]),
|
|
"quality_check_count": len(summary["quality_checks"]),
|
|
"export_count": len(summary["exports"]),
|
|
"readiness_state": summary["readiness_summary"]["overall_state"],
|
|
"format": "html",
|
|
}
|
|
export = ExportService._write_text_export(
|
|
db,
|
|
project_id=project.id,
|
|
analysis_run_id=None,
|
|
export_type="project_report_html",
|
|
storage_path=export_path,
|
|
content=html,
|
|
metadata=metadata,
|
|
)
|
|
return ExportService._create_response(export)
|
|
|
|
@staticmethod
|
|
def list_project_exports(db: Session, project_id: uuid.UUID, limit: int = 50, offset: int = 0) -> ExportListResponse:
|
|
if not db.get(Project, project_id):
|
|
raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404)
|
|
query = db.query(Export).filter(Export.project_id == project_id).order_by(Export.created_at.desc())
|
|
rows = query.offset(offset).limit(limit).all()
|
|
total = query.count()
|
|
return ExportListResponse(
|
|
items=[ExportRead.model_validate(row) for row in rows],
|
|
total=total,
|
|
limit=limit,
|
|
offset=offset,
|
|
)
|
|
|
|
@staticmethod
|
|
def get_export(db: Session, export_id: uuid.UUID) -> ExportRead:
|
|
export = db.get(Export, export_id)
|
|
if not export:
|
|
raise AppError(code="EXPORT_NOT_FOUND", message="Export not found", status_code=404)
|
|
return ExportRead.model_validate(export)
|
|
|
|
@staticmethod
|
|
def get_export_content(db: Session, export_id: uuid.UUID) -> ExportContentResponse:
|
|
export = db.get(Export, export_id)
|
|
if not export:
|
|
raise AppError(code="EXPORT_NOT_FOUND", message="Export not found", status_code=404)
|
|
path = ExportService.get_export_download_path(db, export_id)
|
|
if export.export_type == "project_report_html" or path.suffix.lower() in {".html", ".htm"}:
|
|
raise AppError(
|
|
code="EXPORT_CONTENT_UNSUPPORTED",
|
|
message="Export content preview is only available for JSON and GeoJSON artifacts. Download HTML report artifacts instead.",
|
|
details={"export_type": export.export_type},
|
|
status_code=415,
|
|
)
|
|
try:
|
|
content = json.loads(path.read_text(encoding="utf-8"))
|
|
except json.JSONDecodeError as exc:
|
|
raise AppError(code="EXPORT_CONTENT_INVALID", message="Export artifact is not valid JSON", status_code=422) from exc
|
|
return ExportContentResponse(export_id=export.id, export_type=export.export_type, content=content)
|
|
|
|
@staticmethod
|
|
def get_export_download_path(db: Session, export_id: uuid.UUID) -> Path:
|
|
export = db.get(Export, export_id)
|
|
if not export:
|
|
raise AppError(code="EXPORT_NOT_FOUND", message="Export not found", status_code=404)
|
|
path = Path(export.storage_path)
|
|
if not path.exists() or not path.is_file():
|
|
raise AppError(
|
|
code="EXPORT_CONTENT_NOT_FOUND",
|
|
message="Export artifact is missing from storage",
|
|
details={"storage_path": export.storage_path},
|
|
status_code=404,
|
|
)
|
|
return path
|
|
|
|
@staticmethod
|
|
def _write_json_export(
|
|
db: Session,
|
|
*,
|
|
project_id: uuid.UUID,
|
|
analysis_run_id: uuid.UUID | None,
|
|
export_type: str,
|
|
storage_path: str,
|
|
content: dict[str, Any],
|
|
metadata: dict[str, Any],
|
|
) -> Export:
|
|
path = Path(storage_path)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(json.dumps(content, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
return ExportService._persist_export(
|
|
db,
|
|
project_id=project_id,
|
|
analysis_run_id=analysis_run_id,
|
|
export_type=export_type,
|
|
storage_path=str(path),
|
|
metadata=metadata,
|
|
)
|
|
|
|
@staticmethod
|
|
def _write_text_export(
|
|
db: Session,
|
|
*,
|
|
project_id: uuid.UUID,
|
|
analysis_run_id: uuid.UUID | None,
|
|
export_type: str,
|
|
storage_path: str,
|
|
content: str,
|
|
metadata: dict[str, Any],
|
|
) -> Export:
|
|
path = Path(storage_path)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(content, encoding="utf-8")
|
|
return ExportService._persist_export(
|
|
db,
|
|
project_id=project_id,
|
|
analysis_run_id=analysis_run_id,
|
|
export_type=export_type,
|
|
storage_path=str(path),
|
|
metadata=metadata,
|
|
)
|
|
|
|
@staticmethod
|
|
def _persist_export(
|
|
db: Session,
|
|
*,
|
|
project_id: uuid.UUID,
|
|
analysis_run_id: uuid.UUID | None,
|
|
export_type: str,
|
|
storage_path: str,
|
|
metadata: dict[str, Any],
|
|
) -> Export:
|
|
export = Export(
|
|
id=uuid.uuid4(),
|
|
project_id=project_id,
|
|
analysis_run_id=analysis_run_id,
|
|
export_type=export_type,
|
|
storage_path=storage_path,
|
|
metadata_json=metadata,
|
|
)
|
|
db.add(export)
|
|
db.commit()
|
|
db.refresh(export)
|
|
return export
|
|
|
|
@staticmethod
|
|
def _project_summary(db: Session, project: Project) -> dict[str, Any]:
|
|
areas = db.query(Area).filter(Area.project_id == project.id).order_by(Area.created_at.desc()).all()
|
|
datasets = db.query(Dataset).filter(Dataset.project_id == project.id).order_by(Dataset.created_at.desc()).all()
|
|
quality_checks = (
|
|
db.query(QualityCheck)
|
|
.filter(QualityCheck.project_id == project.id)
|
|
.order_by(QualityCheck.created_at.desc())
|
|
.all()
|
|
)
|
|
exports = db.query(Export).filter(Export.project_id == project.id).order_by(Export.created_at.desc()).all()
|
|
summary = {
|
|
"project": {
|
|
"id": str(project.id),
|
|
"name": project.name,
|
|
"description": project.description,
|
|
"region": project.region,
|
|
"status": project.status,
|
|
},
|
|
"areas": [
|
|
{
|
|
"id": str(area.id),
|
|
"name": area.name,
|
|
"original_crs": area.original_crs,
|
|
"area_m2": area.area_m2,
|
|
"created_at": area.created_at.isoformat() if area.created_at else None,
|
|
}
|
|
for area in areas
|
|
],
|
|
"datasets": [
|
|
{
|
|
"id": str(dataset.id),
|
|
"name": dataset.name,
|
|
"dataset_type": dataset.dataset_type,
|
|
"dataset_role": dataset.dataset_role,
|
|
"source_name": dataset.source_name,
|
|
"reference_layer_name": dataset.reference_layer_name,
|
|
"status": dataset.status,
|
|
"crs": dataset.crs,
|
|
"bounds_json": dataset.bounds_json,
|
|
"feature_count": (dataset.metadata_json or {}).get("feature_count"),
|
|
}
|
|
for dataset in datasets
|
|
],
|
|
"quality_checks": [
|
|
{
|
|
"id": str(check.id),
|
|
"analysis_run_id": str(check.analysis_run_id) if check.analysis_run_id else None,
|
|
"candidate_dataset_id": str(check.candidate_dataset_id) if check.candidate_dataset_id else None,
|
|
"reference_dataset_id": str(check.reference_dataset_id),
|
|
"check_type": check.check_type,
|
|
"status": check.status,
|
|
"score": check.score,
|
|
}
|
|
for check in quality_checks
|
|
],
|
|
"exports": [
|
|
{
|
|
"id": str(export.id),
|
|
"analysis_run_id": str(export.analysis_run_id) if export.analysis_run_id else None,
|
|
"export_type": export.export_type,
|
|
"storage_path": export.storage_path,
|
|
"metadata_json": export.metadata_json,
|
|
"created_at": export.created_at.isoformat() if export.created_at else None,
|
|
}
|
|
for export in exports
|
|
],
|
|
}
|
|
summary["readiness_summary"] = ExportService._build_readiness_summary(summary)
|
|
summary["known_limitations"] = [
|
|
"Report artifact is a lightweight HTML handoff, not a PDF designer.",
|
|
"No live GRB/OSM/Sentinel fetching is performed by the report export.",
|
|
"AI detections or segmentations are included only when they already exist as persisted records/exports.",
|
|
]
|
|
return summary
|
|
|
|
@staticmethod
|
|
def _build_readiness_summary(summary: dict[str, Any]) -> dict[str, Any]:
|
|
project = summary["project"]
|
|
areas = summary["areas"]
|
|
datasets = summary["datasets"]
|
|
quality_checks = summary["quality_checks"]
|
|
exports = summary["exports"]
|
|
|
|
ready_datasets = [dataset for dataset in datasets if dataset["status"] == "ready"]
|
|
vector_datasets = [dataset for dataset in datasets if dataset["dataset_type"] in {"vector", "geojson"}]
|
|
raster_datasets = [dataset for dataset in datasets if dataset["dataset_type"] == "raster"]
|
|
reference_datasets = [dataset for dataset in datasets if dataset["dataset_role"] == "reference"]
|
|
|
|
items = [
|
|
{
|
|
"key": "project",
|
|
"label": "Project",
|
|
"state": "ready" if project["status"] != "deleted" else "blocked",
|
|
"detail": f"{project['name']} ({project['region']})",
|
|
},
|
|
{
|
|
"key": "aoi",
|
|
"label": "AOI",
|
|
"state": "ready" if areas else "waiting",
|
|
"detail": f"{len(areas)} area{'s' if len(areas) != 1 else ''}",
|
|
},
|
|
{
|
|
"key": "datasets",
|
|
"label": "Datasets",
|
|
"state": "ready" if datasets and len(ready_datasets) == len(datasets) else "waiting" if not datasets else "warning",
|
|
"detail": (
|
|
f"{len(ready_datasets)}/{len(datasets)} ready; "
|
|
f"{len(vector_datasets)} vector, {len(raster_datasets)} raster, {len(reference_datasets)} reference"
|
|
),
|
|
},
|
|
{
|
|
"key": "qa",
|
|
"label": "QA/QC",
|
|
"state": "ready" if quality_checks else "waiting",
|
|
"detail": f"{len(quality_checks)} persisted check{'s' if len(quality_checks) != 1 else ''}",
|
|
},
|
|
{
|
|
"key": "exports",
|
|
"label": "Exports",
|
|
"state": "ready" if exports else "waiting",
|
|
"detail": f"{len(exports)} previous export{'s' if len(exports) != 1 else ''}",
|
|
},
|
|
]
|
|
overall_state = "ready" if all(item["state"] == "ready" for item in items) else "needs_attention"
|
|
return {
|
|
"overall_state": overall_state,
|
|
"items": items,
|
|
"counts": {
|
|
"area_count": len(areas),
|
|
"dataset_count": len(datasets),
|
|
"ready_dataset_count": len(ready_datasets),
|
|
"vector_dataset_count": len(vector_datasets),
|
|
"raster_dataset_count": len(raster_datasets),
|
|
"reference_dataset_count": len(reference_datasets),
|
|
"quality_check_count": len(quality_checks),
|
|
"export_count": len(exports),
|
|
},
|
|
}
|
|
|
|
@staticmethod
|
|
def _render_project_report_html(summary: dict[str, Any]) -> str:
|
|
project = summary["project"]
|
|
datasets = summary["datasets"]
|
|
quality_checks = summary["quality_checks"]
|
|
exports = summary["exports"]
|
|
readiness_summary = summary["readiness_summary"]
|
|
known_limitations = summary["known_limitations"]
|
|
counts = readiness_summary["counts"]
|
|
overall_state = str(readiness_summary["overall_state"])
|
|
overall_state_class = ExportService._html_class_token(overall_state)
|
|
generated_context = "Generated from persisted GeoIntel state"
|
|
scorecards = [
|
|
("Areas", counts.get("area_count", 0)),
|
|
("Datasets", f"{counts.get('ready_dataset_count', 0)}/{counts.get('dataset_count', 0)} ready"),
|
|
("Reference", counts.get("reference_dataset_count", 0)),
|
|
("QA/QC", counts.get("quality_check_count", 0)),
|
|
("Exports", counts.get("export_count", 0)),
|
|
]
|
|
scorecard_html = "\n".join(
|
|
"<div class=\"scorecard\">"
|
|
f"<span>{escape(str(label))}</span>"
|
|
f"<strong>{escape(str(value))}</strong>"
|
|
"</div>"
|
|
for label, value in scorecards
|
|
)
|
|
readiness_rows = "\n".join(
|
|
"<tr>"
|
|
f"<td>{escape(str(item['label']))}</td>"
|
|
f"<td><span class=\"readiness-pill readiness-{ExportService._html_class_token(str(item['state']))}\">{escape(str(item['state']))}</span></td>"
|
|
f"<td>{escape(str(item['detail']))}</td>"
|
|
"</tr>"
|
|
for item in readiness_summary["items"]
|
|
)
|
|
limitation_items = "\n".join(f"<li>{escape(str(item))}</li>" for item in known_limitations)
|
|
dataset_rows = "\n".join(
|
|
"<tr>"
|
|
f"<td>{escape(str(item['name']))}</td>"
|
|
f"<td>{escape(str(item['dataset_type']))}</td>"
|
|
f"<td>{escape(str(item['dataset_role']))}</td>"
|
|
f"<td>{escape(str(item['status']))}</td>"
|
|
f"<td>{escape(str(item['feature_count'] if item['feature_count'] is not None else 'n/a'))}</td>"
|
|
f"<td>{escape(str(item.get('source_name') or 'n/a'))}</td>"
|
|
f"<td>{escape(str(item.get('crs') or 'n/a'))}</td>"
|
|
"</tr>"
|
|
for item in datasets
|
|
)
|
|
quality_rows = "\n".join(
|
|
"<tr>"
|
|
f"<td>{escape(str(item['check_type']))}</td>"
|
|
f"<td>{escape(str(item['status']))}</td>"
|
|
f"<td>{escape(str(item['score'] if item['score'] is not None else 'n/a'))}</td>"
|
|
f"<td>{escape(str(item['reference_dataset_id']))}</td>"
|
|
"</tr>"
|
|
for item in quality_checks
|
|
)
|
|
export_rows = "\n".join(
|
|
"<tr>"
|
|
f"<td>{escape(str(item['export_type']))}</td>"
|
|
f"<td>{escape(str(item['storage_path']))}</td>"
|
|
f"<td>{escape(str(item['created_at'] or 'n/a'))}</td>"
|
|
"</tr>"
|
|
for item in exports
|
|
)
|
|
return f"""<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>GeoIntel Project Report - {escape(str(project["name"]))}</title>
|
|
<style>
|
|
:root {{
|
|
--ink: #132018;
|
|
--muted: #5f6f67;
|
|
--line: #cbd8d0;
|
|
--soft: #f3f8f5;
|
|
--accent: #0f766e;
|
|
--accent-soft: #e3f4ef;
|
|
--warning: #b45309;
|
|
--danger: #991b1b;
|
|
}}
|
|
* {{ box-sizing: border-box; }}
|
|
body {{
|
|
margin: 0;
|
|
background: #eef4f1;
|
|
color: var(--ink);
|
|
font-family: Inter, "Segoe UI", Arial, sans-serif;
|
|
line-height: 1.45;
|
|
}}
|
|
.report-shell {{
|
|
width: min(1120px, calc(100% - 2rem));
|
|
margin: 0 auto;
|
|
padding: 1.25rem 0 2rem;
|
|
}}
|
|
.report-hero,
|
|
.report-section {{
|
|
page-break-inside: avoid;
|
|
border: 1px solid var(--line);
|
|
border-radius: 10px;
|
|
background: #fff;
|
|
box-shadow: 0 10px 28px rgba(33, 48, 41, 0.08);
|
|
}}
|
|
.report-hero {{
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1fr) auto;
|
|
gap: 1rem;
|
|
align-items: end;
|
|
padding: 1.2rem;
|
|
background: linear-gradient(135deg, #ffffff, var(--accent-soft));
|
|
}}
|
|
h1, h2, h3, p {{ margin-top: 0; }}
|
|
h1 {{ margin-bottom: 0.35rem; font-size: 2rem; line-height: 1.05; }}
|
|
h2 {{ margin-bottom: 0.65rem; font-size: 1.2rem; }}
|
|
p {{ margin-bottom: 0.55rem; }}
|
|
.muted {{ color: var(--muted); }}
|
|
.section-kicker {{
|
|
margin: 0 0 0.22rem;
|
|
color: var(--muted);
|
|
font-size: 0.72rem;
|
|
font-weight: 800;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
}}
|
|
.readiness-pill {{
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border: 1px solid var(--line);
|
|
border-radius: 999px;
|
|
padding: 0.2rem 0.58rem;
|
|
background: #fff;
|
|
font-size: 0.78rem;
|
|
font-weight: 800;
|
|
line-height: 1.2;
|
|
white-space: nowrap;
|
|
}}
|
|
.readiness-ready {{ border-color: rgba(21, 128, 61, 0.28); background: #ecfdf3; color: #166534; }}
|
|
.readiness-needs_attention,
|
|
.readiness-warning,
|
|
.readiness-waiting {{ border-color: rgba(180, 83, 9, 0.28); background: #fffbeb; color: var(--warning); }}
|
|
.readiness-blocked {{ border-color: rgba(153, 27, 27, 0.28); background: #fff1f2; color: var(--danger); }}
|
|
.report-scorecards {{
|
|
display: grid;
|
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
|
gap: 0.65rem;
|
|
margin: 1rem 0;
|
|
}}
|
|
.scorecard {{
|
|
border: 1px solid var(--line);
|
|
border-radius: 8px;
|
|
padding: 0.7rem;
|
|
background: #fff;
|
|
}}
|
|
.scorecard span {{
|
|
display: block;
|
|
color: var(--muted);
|
|
font-size: 0.72rem;
|
|
font-weight: 800;
|
|
letter-spacing: 0.05em;
|
|
text-transform: uppercase;
|
|
}}
|
|
.scorecard strong {{ display: block; margin-top: 0.25rem; font-size: 1.05rem; }}
|
|
.report-section {{ margin-top: 1rem; padding: 1rem; overflow: hidden; }}
|
|
.table-wrap {{ width: 100%; overflow-x: auto; }}
|
|
table {{ width: 100%; border-collapse: collapse; min-width: 42rem; }}
|
|
th, td {{ border-bottom: 1px solid var(--line); padding: 0.55rem; text-align: left; vertical-align: top; }}
|
|
th {{
|
|
background: var(--soft);
|
|
color: var(--muted);
|
|
font-size: 0.72rem;
|
|
letter-spacing: 0.05em;
|
|
text-transform: uppercase;
|
|
}}
|
|
ul {{ margin: 0; padding-left: 1.2rem; }}
|
|
li + li {{ margin-top: 0.35rem; }}
|
|
@media (max-width: 760px) {{
|
|
.report-shell {{ width: min(100% - 1rem, 1120px); }}
|
|
.report-hero {{ grid-template-columns: 1fr; }}
|
|
.report-scorecards {{ grid-template-columns: repeat(2, minmax(0, 1fr)); }}
|
|
}}
|
|
@media print {{
|
|
body {{ background: #fff; }}
|
|
.report-shell {{ width: 100%; padding: 0; }}
|
|
.report-hero,
|
|
.report-section {{ box-shadow: none; border-color: #94a3b8; page-break-inside: avoid; }}
|
|
.table-wrap {{ overflow: visible; }}
|
|
table {{ min-width: 0; font-size: 0.82rem; }}
|
|
}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main class="report-shell">
|
|
<section class="report-hero">
|
|
<div>
|
|
<p class="section-kicker">GeoIntel project report artifact</p>
|
|
<h1>{escape(str(project["name"]))}</h1>
|
|
<p class="muted">{generated_context}</p>
|
|
<p>Region: {escape(str(project["region"]))} · Status: {escape(str(project["status"]))}</p>
|
|
<p>Description: {escape(str(project["description"] or "n/a"))}</p>
|
|
</div>
|
|
<span class="readiness-pill readiness-{overall_state_class}">{escape(overall_state)}</span>
|
|
</section>
|
|
<div class="report-scorecards">{scorecard_html}</div>
|
|
<section class="report-section">
|
|
<p class="section-kicker">Release handoff</p>
|
|
<h2>V1 Readiness Summary</h2>
|
|
<p>Overall state: <span class="readiness-pill readiness-{overall_state_class}">{escape(overall_state)}</span></p>
|
|
<div class="table-wrap">
|
|
<table>
|
|
<thead><tr><th>Area</th><th>State</th><th>Detail</th></tr></thead>
|
|
<tbody>{readiness_rows}</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
<section class="report-section">
|
|
<p class="section-kicker">Data handoff</p>
|
|
<h2>Dataset inventory ({len(datasets)})</h2>
|
|
<div class="table-wrap">
|
|
<table>
|
|
<thead><tr><th>Name</th><th>Type</th><th>Role</th><th>Status</th><th>Features</th><th>Source</th><th>CRS</th></tr></thead>
|
|
<tbody>{dataset_rows or '<tr><td colspan="7">No datasets</td></tr>'}</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
<section class="report-section">
|
|
<p class="section-kicker">Quality handoff</p>
|
|
<h2>QA/QC evidence ({len(quality_checks)})</h2>
|
|
<div class="table-wrap">
|
|
<table>
|
|
<thead><tr><th>Check</th><th>Status</th><th>Score</th><th>Reference dataset</th></tr></thead>
|
|
<tbody>{quality_rows or '<tr><td colspan="4">No QA/QC results</td></tr>'}</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
<section class="report-section">
|
|
<p class="section-kicker">Artifact handoff</p>
|
|
<h2>Artifact history ({len(exports)})</h2>
|
|
<p class="muted">Export History ({len(exports)})</p>
|
|
<div class="table-wrap">
|
|
<table>
|
|
<thead><tr><th>Type</th><th>Storage path</th><th>Created</th></tr></thead>
|
|
<tbody>{export_rows or '<tr><td colspan="3">No exports</td></tr>'}</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
<section class="report-section">
|
|
<p class="section-kicker">Scope guardrails</p>
|
|
<h2>Known Limitations</h2>
|
|
<ul>{limitation_items}</ul>
|
|
</section>
|
|
</main>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
@staticmethod
|
|
def _html_class_token(value: str) -> str:
|
|
token = re.sub(r"[^a-zA-Z0-9_-]+", "_", value.strip().lower()).strip("_")
|
|
return token or "unknown"
|
|
|
|
@staticmethod
|
|
def _create_response(export: Export) -> ExportCreateResponse:
|
|
return ExportCreateResponse(
|
|
export_id=export.id,
|
|
path=export.storage_path,
|
|
status="ready",
|
|
export_type=export.export_type,
|
|
metadata_json=export.metadata_json,
|
|
)
|
|
|
|
@staticmethod
|
|
def _filename(name: str | None, fallback: str, suffix: str) -> str:
|
|
raw_name = name or fallback
|
|
cleaned = re.sub(r"[^A-Za-z0-9_.-]+", "_", raw_name).strip("._")
|
|
if not cleaned:
|
|
cleaned = fallback
|
|
if not cleaned.lower().endswith(suffix):
|
|
cleaned = f"{cleaned}{suffix}"
|
|
return cleaned
|