Polish project report artifact
GeoIntel CI / docs-smoke (push) Has been cancelled
GeoIntel CI / contract-smoke (push) Has been cancelled

This commit is contained in:
Codex
2026-06-18 23:44:36 +02:00
parent 61b833f119
commit 688c3434fc
6 changed files with 376 additions and 37 deletions
+8
View File
@@ -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.
+11
View File
@@ -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`
+204 -37
View File
@@ -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 &lt;Kempen&gt;" 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 "&lt;script&gt;alert(1)&lt;/script&gt;" in html
assert "<b>unsafe</b>" not in html
assert "&lt;b&gt;unsafe&lt;/b&gt;" in html
assert "&lt;Project&gt;" in html
assert "&lt;unsafe limitation&gt;" in html
assert 'class="readiness-pill readiness-needs_attention"' in html
+27
View File
@@ -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.
+1
View File
@@ -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.