feat(reports): Excel renderer + attachment + archive download
render_excel(report): one worksheet per location — interval table, a line chart,
and a Last/Base/Δ summary per window. Metric-driven, so it tracks whatever metric
set is configured.
- orchestrator: render report.xlsx alongside report.html, attach it to the email
(dry-run until SMTP set), expose xlsx_path. Never lets a spreadsheet error sink
the report.
- reports router: /list includes xlsx_url when present; new
GET /archive/{date}/xlsx serves the saved spreadsheet.
- UI: Recent-reports rows get an "Excel" download link.
Verified: real Feb data -> valid .xlsx (sheet per NRL, interval table + chart +
summary with real values), attachment path runs, both archive routes registered.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -295,6 +295,8 @@ async def list_reports(project_id: str, db: Session = Depends(get_db)):
|
||||
out.append({
|
||||
"night_date": d.name,
|
||||
"view_url": f"/api/projects/{project_id}/reports/archive/{d.name}",
|
||||
"xlsx_url": (f"/api/projects/{project_id}/reports/archive/{d.name}/xlsx"
|
||||
if (d / "report.xlsx").exists() else None),
|
||||
"size_bytes": st.st_size,
|
||||
"generated_at": datetime.utcfromtimestamp(st.st_mtime).isoformat(),
|
||||
})
|
||||
@@ -315,6 +317,25 @@ async def view_archived_report(project_id: str, night_date: str, db: Session = D
|
||||
return HTMLResponse(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
@router.get("/archive/{night_date}/xlsx")
|
||||
async def download_archived_xlsx(project_id: str, night_date: str, db: Session = Depends(get_db)):
|
||||
"""Download a previously generated report.xlsx from disk."""
|
||||
from fastapi.responses import Response
|
||||
if not db.query(Project).filter_by(id=project_id).first():
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
if not _DATE_RE.match(night_date):
|
||||
raise HTTPException(status_code=400, detail="Invalid date (YYYY-MM-DD)")
|
||||
safe = _parse_date(night_date, "night_date")
|
||||
path = Path("data/reports") / project_id / f"{safe:%Y-%m-%d}" / "report.xlsx"
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=404, detail="No saved spreadsheet for that date")
|
||||
return Response(
|
||||
content=path.read_bytes(),
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": f'attachment; filename="night_report_{safe:%Y-%m-%d}.xlsx"'},
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Reference baseline (fixed values typed per location — limits / prior averages)
|
||||
# ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user