432 lines
18 KiB
Python
432 lines
18 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, Dataset, Export, Project, QualityCheck
|
|
from app.schemas.export import ExportContentResponse, ExportCreateResponse, ExportListResponse, ExportRead
|
|
from app.services.dataset_service import DatasetService
|
|
from app.services.detection_service import DetectionService
|
|
from app.services.segmentation_service import SegmentationService
|
|
from app.services.storage_service import StorageService
|
|
|
|
|
|
class ExportService:
|
|
@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"]),
|
|
}
|
|
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"]),
|
|
"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)
|
|
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]:
|
|
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()
|
|
return {
|
|
"project": {
|
|
"id": str(project.id),
|
|
"name": project.name,
|
|
"description": project.description,
|
|
"region": project.region,
|
|
"status": project.status,
|
|
},
|
|
"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
|
|
],
|
|
}
|
|
|
|
@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"]
|
|
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>"
|
|
"</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" />
|
|
<title>GeoIntel Project Report - {escape(str(project["name"]))}</title>
|
|
<style>
|
|
body {{ font-family: Arial, sans-serif; color: #0f172a; margin: 2rem; }}
|
|
h1, h2 {{ margin-bottom: 0.4rem; }}
|
|
table {{ width: 100%; border-collapse: collapse; margin: 1rem 0 2rem; }}
|
|
th, td {{ border: 1px solid #cbd5e1; padding: 0.5rem; text-align: left; }}
|
|
th {{ background: #e2e8f0; }}
|
|
.muted {{ color: #475569; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>{escape(str(project["name"]))}</h1>
|
|
<p class="muted">GeoIntel project report artifact</p>
|
|
<p>Region: {escape(str(project["region"]))}</p>
|
|
<p>Status: {escape(str(project["status"]))}</p>
|
|
<p>Description: {escape(str(project["description"] or "n/a"))}</p>
|
|
<h2>Datasets ({len(datasets)})</h2>
|
|
<table>
|
|
<thead><tr><th>Name</th><th>Type</th><th>Role</th><th>Status</th><th>Features</th></tr></thead>
|
|
<tbody>{dataset_rows or '<tr><td colspan="5">No datasets</td></tr>'}</tbody>
|
|
</table>
|
|
<h2>QA/QC Results ({len(quality_checks)})</h2>
|
|
<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>
|
|
<h2>Export History ({len(exports)})</h2>
|
|
<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>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
@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
|