Initial GeoIntel V1 foundation
This commit is contained in:
@@ -0,0 +1,431 @@
|
||||
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
|
||||
Reference in New Issue
Block a user