Polish project report artifact
This commit is contained in:
@@ -703,3 +703,11 @@ Added:
|
||||
- Added clearer export history provenance with formatted export-type badges, analysis-run ids and created timestamps when available.
|
||||
- Added regression coverage for the Export Center handoff structure and responsive styling.
|
||||
- No API contracts, migrations, product capabilities, live provider fetching or AI/model dependency changes were introduced.
|
||||
|
||||
## Sprint 65 project report readability polish (2026-06-18)
|
||||
|
||||
- Reworked the lightweight HTML project report template into a self-contained handoff layout with hero, readiness pill, scorecards and sectioned tables.
|
||||
- Added print-friendly CSS and scroll-safe table wrappers while preserving the existing `project_report_html` export type and download behavior.
|
||||
- Added source/CRS columns to the dataset inventory section and clearer "Dataset inventory", "QA/QC evidence" and "Artifact history" report headings.
|
||||
- Added regression coverage for report layout markers, print CSS and HTML escaping.
|
||||
- No API contracts, migrations, product capabilities, PDF/report-designer functionality, live provider fetching or AI/model dependency changes were introduced.
|
||||
|
||||
@@ -563,6 +563,17 @@ If `rasterio` is unavailable:
|
||||
- raster metadata responses return `503` with `RASTER_PROCESSING_UNAVAILABLE`
|
||||
- raster clip/tile endpoints return explicit unavailable responses
|
||||
|
||||
## Export report artifact
|
||||
|
||||
`POST /api/v1/exports/report` creates the existing lightweight
|
||||
`project_report_html` artifact. The report is a self-contained HTML handoff
|
||||
view rendered from persisted project, dataset, QA/QC and export-history state.
|
||||
It includes readiness scorecards, dataset inventory, QA/QC evidence, artifact
|
||||
history, known limitations and print-friendly CSS.
|
||||
|
||||
This remains a simple HTML export. It does not add a PDF designer, report
|
||||
builder, live provider fetching or new analysis behavior.
|
||||
|
||||
## Helpful repository scripts
|
||||
|
||||
- `bash scripts/backend_install.sh`
|
||||
|
||||
@@ -437,10 +437,28 @@ class ExportService:
|
||||
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>{escape(str(item['state']))}</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"]
|
||||
@@ -453,6 +471,8 @@ class ExportService:
|
||||
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
|
||||
)
|
||||
@@ -477,51 +497,198 @@ class ExportService:
|
||||
<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>
|
||||
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; }}
|
||||
.status-ready {{ color: #166534; font-weight: 700; }}
|
||||
.status-needs_attention {{ color: #92400e; font-weight: 700; }}
|
||||
: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>
|
||||
<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>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>
|
||||
<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>
|
||||
<h2>Known Limitations</h2>
|
||||
<ul>{limitation_items}</ul>
|
||||
<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(
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.services.export_service import ExportService
|
||||
|
||||
|
||||
def test_project_report_html_uses_handoff_layout_and_print_styles() -> None:
|
||||
summary = {
|
||||
"project": {
|
||||
"id": "project-1",
|
||||
"name": "Demo <Kempen>",
|
||||
"description": "QA handoff",
|
||||
"region": "Kempen",
|
||||
"status": "active",
|
||||
},
|
||||
"datasets": [
|
||||
{
|
||||
"id": "dataset-1",
|
||||
"name": "reference.geojson",
|
||||
"dataset_type": "vector",
|
||||
"dataset_role": "reference",
|
||||
"source_name": "fixture",
|
||||
"reference_layer_name": "buildings",
|
||||
"status": "ready",
|
||||
"crs": "EPSG:4326",
|
||||
"bounds_json": None,
|
||||
"feature_count": 2,
|
||||
}
|
||||
],
|
||||
"quality_checks": [
|
||||
{
|
||||
"id": "quality-1",
|
||||
"analysis_run_id": None,
|
||||
"candidate_dataset_id": "candidate-1",
|
||||
"reference_dataset_id": "dataset-1",
|
||||
"check_type": "demo_candidate_vs_reference",
|
||||
"status": "ok",
|
||||
"score": 0.75,
|
||||
}
|
||||
],
|
||||
"exports": [
|
||||
{
|
||||
"id": "export-1",
|
||||
"analysis_run_id": None,
|
||||
"export_type": "project_metadata_json",
|
||||
"storage_path": "storage/exports/metadata.json",
|
||||
"metadata_json": {"readiness_state": "ready"},
|
||||
"created_at": "2026-06-18T10:00:00+00:00",
|
||||
}
|
||||
],
|
||||
"readiness_summary": {
|
||||
"overall_state": "ready",
|
||||
"items": [
|
||||
{"key": "project", "label": "Project", "state": "ready", "detail": "Demo <Kempen> (Kempen)"},
|
||||
{"key": "datasets", "label": "Datasets", "state": "ready", "detail": "1/1 ready"},
|
||||
],
|
||||
"counts": {
|
||||
"area_count": 1,
|
||||
"dataset_count": 1,
|
||||
"ready_dataset_count": 1,
|
||||
"vector_dataset_count": 1,
|
||||
"raster_dataset_count": 0,
|
||||
"reference_dataset_count": 1,
|
||||
"quality_check_count": 1,
|
||||
"export_count": 1,
|
||||
},
|
||||
},
|
||||
"known_limitations": ["No live GRB/OSM/Sentinel fetching is performed by the report export."],
|
||||
}
|
||||
|
||||
html = ExportService._render_project_report_html(summary)
|
||||
|
||||
assert 'class="report-shell"' in html
|
||||
assert 'class="report-hero"' in html
|
||||
assert 'class="report-scorecards"' in html
|
||||
assert 'class="readiness-pill readiness-ready"' in html
|
||||
assert 'class="section-kicker"' in html
|
||||
assert "@media print" in html
|
||||
assert "page-break-inside: avoid" in html
|
||||
assert "Generated from persisted GeoIntel state" in html
|
||||
assert "Dataset inventory" in html
|
||||
assert "QA/QC evidence" in html
|
||||
assert "Artifact history" in html
|
||||
assert "Demo <Kempen>" in html
|
||||
|
||||
|
||||
def test_project_report_html_escapes_table_values_in_polished_layout() -> None:
|
||||
summary = {
|
||||
"project": {
|
||||
"id": "project-1",
|
||||
"name": "<script>alert(1)</script>",
|
||||
"description": "<b>unsafe</b>",
|
||||
"region": "Kempen",
|
||||
"status": "active",
|
||||
},
|
||||
"datasets": [],
|
||||
"quality_checks": [],
|
||||
"exports": [],
|
||||
"readiness_summary": {
|
||||
"overall_state": "needs_attention",
|
||||
"items": [
|
||||
{"key": "project", "label": "<Project>", "state": "waiting", "detail": "<missing>"},
|
||||
],
|
||||
"counts": {
|
||||
"area_count": 0,
|
||||
"dataset_count": 0,
|
||||
"ready_dataset_count": 0,
|
||||
"vector_dataset_count": 0,
|
||||
"raster_dataset_count": 0,
|
||||
"reference_dataset_count": 0,
|
||||
"quality_check_count": 0,
|
||||
"export_count": 0,
|
||||
},
|
||||
},
|
||||
"known_limitations": ["<unsafe limitation>"],
|
||||
}
|
||||
|
||||
html = ExportService._render_project_report_html(summary)
|
||||
|
||||
assert "<script>alert(1)</script>" not in html
|
||||
assert "<script>alert(1)</script>" in html
|
||||
assert "<b>unsafe</b>" not in html
|
||||
assert "<b>unsafe</b>" in html
|
||||
assert "<Project>" in html
|
||||
assert "<unsafe limitation>" in html
|
||||
assert 'class="readiness-pill readiness-needs_attention"' in html
|
||||
@@ -2489,3 +2489,30 @@ Limitations:
|
||||
|
||||
Next recommended pass:
|
||||
- Run a live browser smoke across Exports and Overview after deployment, then continue with report artifact readability if the exported HTML itself needs visual polish.
|
||||
|
||||
## Sprint 65 Project report readability polish (2026-06-18)
|
||||
|
||||
Changed:
|
||||
- Reworked the lightweight `project_report_html` renderer into a self-contained handoff layout with hero, readiness pill, scorecards and sectioned report content.
|
||||
- Added print-friendly CSS and scroll-safe table wrappers to the HTML artifact.
|
||||
- Added source and CRS columns to the dataset inventory section.
|
||||
- Preserved existing export type, endpoint behavior, download behavior and storage flow.
|
||||
- Added `backend/tests/test_sprint65_project_report_polish.py`.
|
||||
- Updated `backend/README.md`, `docs/TODO.md` and `CHANGELOG.md`.
|
||||
|
||||
Tested:
|
||||
- Red step: `python -m pytest backend/tests/test_sprint65_project_report_polish.py -q` failed on missing report shell, scorecards, print styles and readiness pill classes.
|
||||
- `python -m pytest backend/tests/test_sprint65_project_report_polish.py backend/tests/test_sprint17_export_foundation.py -q` (`12 passed`)
|
||||
- `python -m compileall backend/app`
|
||||
- `cd frontend && npm run typecheck`
|
||||
- `cd frontend && npm run build`
|
||||
- `bash scripts/run_readiness_check.sh` (`235 passed`)
|
||||
|
||||
Open:
|
||||
- Run full readiness, deploy Tower and smoke the HTML report download visually.
|
||||
|
||||
Limitations:
|
||||
- This remains a lightweight HTML handoff artifact. It does not add PDF generation, a report designer, new endpoints, live provider fetching or AI/model behavior.
|
||||
|
||||
Next recommended pass:
|
||||
- Verify the generated report artifact through the live export workflow, then continue with a full-workspace browser smoke if the runtime stays green.
|
||||
|
||||
@@ -337,3 +337,4 @@ This file now starts with the current implementation status. Older preparation/b
|
||||
- [x] Add workbench visual polish pass for command bar, panel surfaces, empty states and mobile nav density.
|
||||
- [x] Add map/result overlay ergonomics for active layer provenance and feature property summaries.
|
||||
- [x] Add export/report handoff polish for artifact readiness, action grouping and export provenance.
|
||||
- [x] Polish lightweight HTML project report readability, print styling and handoff sections.
|
||||
|
||||
Reference in New Issue
Block a user