Add V1 report handoff summary
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
"<tr>"
|
||||
f"<td>{escape(str(item['label']))}</td>"
|
||||
f"<td>{escape(str(item['state']))}</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>"
|
||||
@@ -383,6 +478,8 @@ class ExportService:
|
||||
th, td {{ border: 1px solid #cbd5e1; padding: 0.5rem; text-align: left; }}
|
||||
th {{ background: #e2e8f0; }}
|
||||
.muted {{ color: #475569; }}
|
||||
.status-ready {{ color: #166534; font-weight: 700; }}
|
||||
.status-needs_attention {{ color: #92400e; font-weight: 700; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -391,6 +488,12 @@ class ExportService:
|
||||
<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>V1 Readiness Summary</h2>
|
||||
<p>Overall state: <span class="status-{escape(str(readiness_summary['overall_state']))}">{escape(str(readiness_summary["overall_state"]))}</span></p>
|
||||
<table>
|
||||
<thead><tr><th>Area</th><th>State</th><th>Detail</th></tr></thead>
|
||||
<tbody>{readiness_rows}</tbody>
|
||||
</table>
|
||||
<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>
|
||||
@@ -406,6 +509,8 @@ class ExportService:
|
||||
<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>
|
||||
<h2>Known Limitations</h2>
|
||||
<ul>{limitation_items}</ul>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
@@ -8,7 +8,7 @@ from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.errors import AppError
|
||||
from app.main import app
|
||||
from app.models import Dataset, Export, Project, QualityCheck
|
||||
from app.models import Area, Dataset, Export, Project, QualityCheck
|
||||
from app.schemas.export import ExportCreateResponse
|
||||
from app.services.export_service import ExportService
|
||||
from app.services.storage_service import StorageService
|
||||
@@ -121,6 +121,7 @@ def test_project_metadata_export_persists_json_summary(tmp_path, monkeypatch) ->
|
||||
dataset_id = uuid4()
|
||||
quality_check_id = uuid4()
|
||||
project = Project(id=project_id, name="Demo", region="Kempen", status="active")
|
||||
area = Area(id=uuid4(), project_id=project_id, name="Demo AOI", original_crs="EPSG:4326", area_m2=100.0)
|
||||
dataset = Dataset(
|
||||
id=dataset_id,
|
||||
project_id=project_id,
|
||||
@@ -154,6 +155,7 @@ def test_project_metadata_export_persists_json_summary(tmp_path, monkeypatch) ->
|
||||
db = FakeSession(
|
||||
{
|
||||
(Project, project_id): project,
|
||||
(Area, area.id): area,
|
||||
(Dataset, dataset_id): dataset,
|
||||
(QualityCheck, quality_check_id): quality_check,
|
||||
(Export, previous_export_id): previous_export,
|
||||
@@ -166,16 +168,22 @@ def test_project_metadata_export_persists_json_summary(tmp_path, monkeypatch) ->
|
||||
payload = json.loads(export_path.read_text(encoding="utf-8"))
|
||||
assert response.export_type == "project_metadata_json"
|
||||
assert payload["project"]["id"] == str(project_id)
|
||||
assert payload["areas"][0]["name"] == "Demo AOI"
|
||||
assert payload["readiness_summary"]["overall_state"] == "ready"
|
||||
assert payload["readiness_summary"]["counts"]["area_count"] == 1
|
||||
assert payload["known_limitations"]
|
||||
assert payload["datasets"][0]["id"] == str(dataset_id)
|
||||
assert payload["quality_checks"][0]["id"] == str(quality_check_id)
|
||||
assert payload["exports"][0]["id"] == str(previous_export_id)
|
||||
assert response.metadata_json["export_count"] == 1
|
||||
assert response.metadata_json["readiness_state"] == "ready"
|
||||
|
||||
|
||||
def test_project_report_export_persists_html_artifact(tmp_path, monkeypatch) -> None:
|
||||
project_id = uuid4()
|
||||
dataset_id = uuid4()
|
||||
project = Project(id=project_id, name="Demo <Kempen>", description="QA report", region="Kempen", status="active")
|
||||
area = Area(id=uuid4(), project_id=project_id, name="Demo AOI", original_crs="EPSG:4326", area_m2=100.0)
|
||||
dataset = Dataset(
|
||||
id=dataset_id,
|
||||
project_id=project_id,
|
||||
@@ -196,7 +204,24 @@ def test_project_report_export_persists_html_artifact(tmp_path, monkeypatch) ->
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
export_path = tmp_path / "report.html"
|
||||
db = FakeSession({(Project, project_id): project, (Dataset, dataset_id): dataset, (Export, previous_export_id): previous_export})
|
||||
quality_check = QualityCheck(
|
||||
id=uuid4(),
|
||||
project_id=project_id,
|
||||
reference_dataset_id=dataset_id,
|
||||
check_type="demo_candidate_vs_reference",
|
||||
status="ok",
|
||||
score=0.5,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db = FakeSession(
|
||||
{
|
||||
(Project, project_id): project,
|
||||
(Area, area.id): area,
|
||||
(Dataset, dataset_id): dataset,
|
||||
(QualityCheck, quality_check.id): quality_check,
|
||||
(Export, previous_export_id): previous_export,
|
||||
}
|
||||
)
|
||||
monkeypatch.setattr(StorageService, "dataset_export_path", lambda *_args: str(export_path))
|
||||
|
||||
response = ExportService.export_project_report(db, project_id)
|
||||
@@ -204,8 +229,12 @@ def test_project_report_export_persists_html_artifact(tmp_path, monkeypatch) ->
|
||||
html = export_path.read_text(encoding="utf-8")
|
||||
assert response.export_type == "project_report_html"
|
||||
assert response.metadata_json["format"] == "html"
|
||||
assert response.metadata_json["readiness_state"] == "ready"
|
||||
assert "<!doctype html>" in html
|
||||
assert "Demo <Kempen>" in html
|
||||
assert "V1 Readiness Summary" in html
|
||||
assert "Overall state:" in html
|
||||
assert "No live GRB/OSM/Sentinel fetching is performed by the report export." in html
|
||||
assert "reference.geojson" in html
|
||||
assert "Export History (1)" in html
|
||||
assert "project_metadata_json" in html
|
||||
|
||||
@@ -1026,8 +1026,9 @@ Export annotations/detections to YOLO format.
|
||||
### POST `/api/v1/exports/report`
|
||||
|
||||
Creates a lightweight HTML project report artifact from persisted project,
|
||||
dataset, QA/QC summary and export history state. This does not create a PDF
|
||||
and does not introduce a report designer.
|
||||
dataset, V1 readiness summary, QA/QC summary, known limitations and export
|
||||
history state. This does not create a PDF and does not introduce a report
|
||||
designer.
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
## Sprint 23 V1 report handoff summary (2026-06-17)
|
||||
|
||||
Changed:
|
||||
- Added V1 readiness summary data to project metadata exports.
|
||||
- Added V1 Readiness Summary and Known Limitations sections to lightweight HTML project report exports.
|
||||
- Included persisted AOI, dataset readiness, QA/QC and export-history counts in the handoff summary.
|
||||
- Updated export tests, API contract docs, frontend README, TODO and changelog.
|
||||
|
||||
Tested:
|
||||
- Passed: backend compile, export tests through full readiness, full backend pytest, frontend typecheck/build, Alembic heads, Alembic SQL upgrade and live smoke syntax check.
|
||||
|
||||
Known limitations:
|
||||
- Report export remains a lightweight HTML artifact, not a PDF designer or custom report builder.
|
||||
|
||||
Next recommended pass:
|
||||
- Add optional cleanup tooling for stale demo/export artifacts if repeated smoke runs keep accumulating local artifacts.
|
||||
## Sprint 22 V1 workbench status strip (2026-06-17)
|
||||
|
||||
Changed:
|
||||
|
||||
@@ -30,6 +30,7 @@ This file now starts with the current implementation status. Older preparation/b
|
||||
- [x] Project-scoped QA/QC result listing and frontend QA/QC Results panel.
|
||||
- [x] Persisted export foundation for vector/detection/segmentation GeoJSON and project metadata JSON.
|
||||
- [x] Lightweight HTML project report artifact export.
|
||||
- [x] V1 readiness handoff summary in project metadata/report exports.
|
||||
- [x] Browser-facing demo/export workflow smoke script with connected V1 state checks.
|
||||
- [x] Compact V1 workbench status strip for project, AOI, datasets, map, QA/QC and exports.
|
||||
- [x] Live Docker/PostGIS validation on Tower/Unraid.
|
||||
|
||||
@@ -164,6 +164,12 @@ React + TypeScript + MapLibre foundation for project/area/dataset workflow.
|
||||
- The status strip is implemented in `src/components/WorkbenchStatusStrip.tsx` and remains driven by `App.tsx` orchestration state.
|
||||
- MapLibre source/layer updates now wait for style readiness to avoid runtime blank-screen failures during first render.
|
||||
|
||||
## Sprint 23 additions
|
||||
|
||||
- Project report HTML exports now include a V1 readiness summary.
|
||||
- The report summary covers project, AOI, datasets, QA/QC, exports and known limitations.
|
||||
- Export Center behavior is unchanged; the richer handoff content is produced by the existing project report export action.
|
||||
|
||||
## Release hardening updates
|
||||
|
||||
- Production builds split application code, React vendor code and MapLibre vendor code into separate chunks.
|
||||
|
||||
Reference in New Issue
Block a user