diff --git a/CHANGELOG.md b/CHANGELOG.md index ce361cbe..9903d059 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ # Changelog +## Sprint 23 V1 report handoff summary (2026-06-17) + +- Added V1 readiness summary data to project metadata exports. +- Added a V1 Readiness Summary and Known Limitations section to lightweight HTML project reports. +- The summary covers project, AOI, dataset readiness, QA/QC and export history using persisted state. +- No new report designer, PDF generation, provider fetching, AI inference, migrations or API route changes were introduced. + ## Sprint 22 V1 workbench status strip (2026-06-17) - Added a compact frontend status strip for project, AOI, datasets, active map layer, QA/QC and exports. diff --git a/backend/app/services/export_service.py b/backend/app/services/export_service.py index 1af38204..163a108d 100644 --- a/backend/app/services/export_service.py +++ b/backend/app/services/export_service.py @@ -10,7 +10,7 @@ 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.models import AnalysisRun, Area, 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 @@ -122,6 +122,7 @@ class ExportService: "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, @@ -150,6 +151,7 @@ class ExportService: "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( @@ -282,6 +284,7 @@ class ExportService: @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) @@ -290,7 +293,7 @@ class ExportService: .all() ) exports = db.query(Export).filter(Export.project_id == project.id).order_by(Export.created_at.desc()).all() - return { + summary = { "project": { "id": str(project.id), "name": project.name, @@ -298,6 +301,16 @@ class ExportService: "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), @@ -337,6 +350,77 @@ class ExportService: 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: @@ -344,6 +428,17 @@ class ExportService: datasets = summary["datasets"] quality_checks = summary["quality_checks"] exports = summary["exports"] + readiness_summary = summary["readiness_summary"] + known_limitations = summary["known_limitations"] + readiness_rows = "\n".join( + "
Region: {escape(str(project["region"]))}
Status: {escape(str(project["status"]))}
Description: {escape(str(project["description"] or "n/a"))}
+Overall state: {escape(str(readiness_summary["overall_state"]))}
+| Area | State | Detail |
|---|
| Name | Type | Role | Status | Features |
|---|---|---|---|---|
| Type | Storage path | Created | ||
| No exports | ||||