diff --git a/.last-branch b/.last-branch index c3bd239..13abcc4 100644 --- a/.last-branch +++ b/.last-branch @@ -1 +1 @@ -v20260103-18-reports-job-filter-selectbox-size-6-rows +v20260103-19-reports-output-format-html diff --git a/containers/backupchecks/requirements.txt b/containers/backupchecks/requirements.txt index 102ffd8..5db362e 100644 --- a/containers/backupchecks/requirements.txt +++ b/containers/backupchecks/requirements.txt @@ -6,3 +6,4 @@ psycopg2-binary==2.9.9 python-dateutil==2.9.0.post0 gunicorn==23.0.0 requests==2.32.3 +reportlab==4.2.5 diff --git a/containers/backupchecks/src/backend/app/main/routes_reporting_api.py b/containers/backupchecks/src/backend/app/main/routes_reporting_api.py index 1a14c76..1711e03 100644 --- a/containers/backupchecks/src/backend/app/main/routes_reporting_api.py +++ b/containers/backupchecks/src/backend/app/main/routes_reporting_api.py @@ -675,75 +675,48 @@ def _normalize_status_row(status: str, missed: bool) -> str: return "unknown" -@main_bp.route("/api/reports//stats", methods=["GET"]) -@login_required -def api_reports_stats(report_id: int): - """Return aggregated KPI + chart data for a report. - Designed to support the "Overview" tab (KPIs + charts) described in the reporting proposal. - """ - err = _require_reporting_role() - if err is not None: - return err +def _build_report_stats_payload(report_id: int) -> dict: + """Compute summary and chart datasets for a generated report.""" report = ReportDefinition.query.get_or_404(report_id) - # If the report hasn't been generated yet, these tables can be empty. - # Return empty-but-valid structures so the UI can render deterministically. - engine = db.get_engine() - - with engine.begin() as conn: - # KPI (runs) - kpi = conn.execute( + with db.engine.connect() as conn: + status_rows = conn.execute( text( """ SELECT - COUNT(*)::INTEGER AS total_runs, - SUM(CASE WHEN (COALESCE(status,'') ILIKE 'success%' AND override_applied = FALSE AND missed = FALSE) THEN 1 ELSE 0 END)::INTEGER AS success_runs, - SUM(CASE WHEN override_applied = TRUE AND missed = FALSE THEN 1 ELSE 0 END)::INTEGER AS success_override_runs, - SUM(CASE WHEN (COALESCE(status,'') ILIKE 'warning%' AND missed = FALSE) THEN 1 ELSE 0 END)::INTEGER AS warning_runs, - SUM(CASE WHEN (COALESCE(status,'') ILIKE 'fail%' AND missed = FALSE) THEN 1 ELSE 0 END)::INTEGER AS failed_runs, - SUM(CASE WHEN missed = TRUE THEN 1 ELSE 0 END)::INTEGER AS missed_runs, - COUNT(DISTINCT job_id)::INTEGER AS total_jobs + COALESCE(status,'') AS status, + missed AS missed, + COUNT(*)::INTEGER AS cnt FROM report_object_snapshots WHERE report_id = :rid + GROUP BY 1, 2 """ ), {"rid": report_id}, - ).fetchone() + ).fetchall() - total_runs = int(kpi.total_runs or 0) if kpi else 0 - success_runs = int(kpi.success_runs or 0) if kpi else 0 - success_override_runs = int(kpi.success_override_runs or 0) if kpi else 0 - warning_runs = int(kpi.warning_runs or 0) if kpi else 0 - failed_runs = int(kpi.failed_runs or 0) if kpi else 0 - missed_runs = int(kpi.missed_runs or 0) if kpi else 0 - total_jobs = int(kpi.total_jobs or 0) if kpi else 0 + status_distribution = { + "success": 0, + "warning": 0, + "failed": 0, + "missed": 0, + "unknown": 0, + } + for sr in status_rows or []: + key = _normalize_status_row(sr.status, bool(sr.missed)) + status_distribution[key] = int(status_distribution.get(key, 0)) + int(sr.cnt or 0) - success_rate = 0.0 - if total_runs > 0: - success_rate = ((success_runs + success_override_runs) / float(total_runs)) * 100.0 - - success_rate = round(success_rate, 2) - # Status distribution (for donut/pie) - status_distribution = [ - {"key": "success", "value": success_runs + success_override_runs}, - {"key": "warning", "value": warning_runs}, - {"key": "failed", "value": failed_runs}, - {"key": "missed", "value": missed_runs}, - ] - - # Trends over time (day buckets) - # Note: uses report.period_start/end so the UI can render the x-axis consistently. + # Success rate trend over time (daily) trend_rows = conn.execute( text( """ SELECT DATE_TRUNC('day', run_at) AS day, - SUM(CASE WHEN (COALESCE(status,'') ILIKE 'success%' AND override_applied = FALSE AND missed = FALSE) THEN 1 ELSE 0 END)::INTEGER AS success_runs, - SUM(CASE WHEN (override_applied = TRUE AND missed = FALSE) THEN 1 ELSE 0 END)::INTEGER AS success_override_runs, - SUM(CASE WHEN (COALESCE(status,'') ILIKE 'warning%' AND missed = FALSE) THEN 1 ELSE 0 END)::INTEGER AS warning_runs, - SUM(CASE WHEN (COALESCE(status,'') ILIKE 'fail%' AND missed = FALSE) THEN 1 ELSE 0 END)::INTEGER AS failed_runs, + SUM(CASE WHEN (COALESCE(status,'') ILIKE 'Success%%' OR override_applied = TRUE) AND missed = FALSE THEN 1 ELSE 0 END)::INTEGER AS success_runs, + SUM(CASE WHEN COALESCE(status,'') ILIKE 'Warning%%' AND missed = FALSE THEN 1 ELSE 0 END)::INTEGER AS warning_runs, + SUM(CASE WHEN COALESCE(status,'') ILIKE 'Fail%%' AND missed = FALSE THEN 1 ELSE 0 END)::INTEGER AS failed_runs, SUM(CASE WHEN missed = TRUE THEN 1 ELSE 0 END)::INTEGER AS missed_runs, COUNT(*)::INTEGER AS total_runs FROM report_object_snapshots @@ -759,13 +732,13 @@ def api_reports_stats(report_id: int): trends = [] for tr in trend_rows or []: day_total = int(tr.total_runs or 0) - day_success = int(tr.success_runs or 0) + int(tr.success_override_runs or 0) + day_success = int(tr.success_runs or 0) day_rate = 0.0 if day_total > 0: day_rate = (day_success / float(day_total)) * 100.0 trends.append( { - "day": tr.day.isoformat() if tr.day else "", + "day": tr.day.date().isoformat() if tr.day else "", "success_rate": round(day_rate, 2), "failed_runs": int(tr.failed_runs or 0), "warning_runs": int(tr.warning_runs or 0), @@ -774,24 +747,84 @@ def api_reports_stats(report_id: int): } ) - # Performance placeholders (requires duration/data extraction work in later phases) - performance = { - "avg_runtime_seconds": None, - "top_jobs_by_runtime": [], - "top_jobs_by_data": [], + # Performance by customer (for summary view) + perf_rows = conn.execute( + text( + """ + SELECT + COALESCE(customer_name, '') AS customer_name, + COUNT(*)::INTEGER AS total_runs, + SUM(CASE WHEN (COALESCE(status,'') ILIKE 'Success%%' OR override_applied = TRUE) AND missed = FALSE THEN 1 ELSE 0 END)::INTEGER AS success_count, + SUM(CASE WHEN COALESCE(status,'') ILIKE 'Warning%%' AND missed = FALSE THEN 1 ELSE 0 END)::INTEGER AS warning_count, + SUM(CASE WHEN COALESCE(status,'') ILIKE 'Fail%%' AND missed = FALSE THEN 1 ELSE 0 END)::INTEGER AS failed_count, + SUM(CASE WHEN missed = TRUE THEN 1 ELSE 0 END)::INTEGER AS missed_count + FROM report_object_snapshots + WHERE report_id = :rid + GROUP BY 1 + ORDER BY 1 ASC + """ + ), + {"rid": report_id}, + ).fetchall() + + performance = [] + totals = { + "total_runs": 0, + "success_count": 0, + "warning_count": 0, + "failed_count": 0, + "missed_count": 0, } + for pr in perf_rows or []: + total_runs = int(pr.total_runs or 0) + success_count = int(pr.success_count or 0) + warning_count = int(pr.warning_count or 0) + failed_count = int(pr.failed_count or 0) + missed_count = int(pr.missed_count or 0) + + totals["total_runs"] += total_runs + totals["success_count"] += success_count + totals["warning_count"] += warning_count + totals["failed_count"] += failed_count + totals["missed_count"] += missed_count + + rate = 0.0 + if total_runs > 0: + rate = (success_count / float(total_runs)) * 100.0 + + performance.append( + { + "customer_name": pr.customer_name or "", + "total_runs": total_runs, + "success_count": success_count, + "warning_count": warning_count, + "failed_count": failed_count, + "missed_count": missed_count, + "success_rate": round(rate, 2), + } + ) + + overall_rate = 0.0 + if totals["total_runs"] > 0: + overall_rate = (totals["success_count"] / float(totals["total_runs"])) * 100.0 return { - "period_start": report.period_start.isoformat() if report.period_start else "", - "period_end": report.period_end.isoformat() if report.period_end else "", - "kpis": { - "total_jobs": total_jobs, - "total_runs": total_runs, - "success_runs": success_runs + success_override_runs, - "warning_runs": warning_runs, - "failed_runs": failed_runs, - "missed_runs": missed_runs, - "success_rate": success_rate, + "report": { + "id": report.id, + "name": report.name, + "report_type": report.report_type, + "output_format": report.output_format, + "period_start": report.period_start.isoformat() if report.period_start else "", + "period_end": report.period_end.isoformat() if report.period_end else "", + "created_at": report.created_at.isoformat() if report.created_at else "", + }, + "summary": { + "total_runs": totals["total_runs"], + "success_count": totals["success_count"], + "warning_count": totals["warning_count"], + "failed_count": totals["failed_count"], + "missed_count": totals["missed_count"], + "success_rate": round(overall_rate, 2), }, "charts": { "status_distribution": status_distribution, @@ -801,100 +834,554 @@ def api_reports_stats(report_id: int): } -@main_bp.route("/api/reports//export.csv", methods=["GET"]) + +@main_bp.route("/api/reports//stats", methods=["GET"]) @login_required -def api_reports_export_csv(report_id: int): +def api_reports_stats(report_id: int): + err = _require_reporting_role() + if err is not None: + return err + + return _build_report_stats_payload(report_id) + + + +def _export_csv_response(report: ReportDefinition, report_id: int, view: str): + view = (view or "summary").strip().lower() + if view not in ("summary", "snapshot"): + view = "summary" + + with db.engine.connect() as conn: + if view == "summary": + rows = conn.execute( + text( + """ + SELECT + COALESCE(customer_name,'') AS customer_name, + COUNT(*)::INTEGER AS total_runs, + SUM(CASE WHEN (COALESCE(status,'') ILIKE 'Success%%' OR override_applied = TRUE) AND missed = FALSE THEN 1 ELSE 0 END)::INTEGER AS success_count, + SUM(CASE WHEN COALESCE(status,'') ILIKE 'Warning%%' AND missed = FALSE THEN 1 ELSE 0 END)::INTEGER AS warning_count, + SUM(CASE WHEN COALESCE(status,'') ILIKE 'Fail%%' AND missed = FALSE THEN 1 ELSE 0 END)::INTEGER AS failed_count, + SUM(CASE WHEN missed = TRUE THEN 1 ELSE 0 END)::INTEGER AS missed_count + FROM report_object_snapshots + WHERE report_id = :rid + GROUP BY 1 + ORDER BY 1 ASC + """ + ), + {"rid": report_id}, + ).fetchall() + + output = io.StringIO() + writer = csv.writer(output) + writer.writerow([ + "customer_name", + "total_runs", + "success_count", + "warning_count", + "failed_count", + "missed_count", + "success_rate", + ]) + for r in rows or []: + total_runs = int(r.total_runs or 0) + success_count = int(r.success_count or 0) + rate = 0.0 + if total_runs > 0: + rate = (success_count / float(total_runs)) * 100.0 + writer.writerow([ + r.customer_name or "", + total_runs, + success_count, + int(r.warning_count or 0), + int(r.failed_count or 0), + int(r.missed_count or 0), + round(rate, 2), + ]) + filename = f"report-{report_id}-summary.csv" + else: + rows = conn.execute( + text( + """ + SELECT + COALESCE(object_name,'') AS object_name, + COALESCE(customer_name,'') AS customer_name, + COALESCE(job_id,'') AS job_id, + COALESCE(job_name,'') AS job_name, + COALESCE(backup_software,'') AS backup_software, + COALESCE(backup_type,'') AS backup_type, + COALESCE(run_id,'') AS run_id, + run_at, + COALESCE(status,'') AS status, + missed, + override_applied, + reviewed_at, + COALESCE(ticket_number,'') AS ticket_number, + COALESCE(remark,'') AS remark + FROM report_object_snapshots + WHERE report_id = :rid + ORDER BY customer_name ASC, object_name ASC + """ + ), + {"rid": report_id}, + ).fetchall() + + output = io.StringIO() + writer = csv.writer(output) + writer.writerow([ + "object_name", + "customer_name", + "job_id", + "job_name", + "backup_software", + "backup_type", + "run_id", + "run_at", + "status", + "missed", + "override_applied", + "reviewed_at", + "ticket_number", + "remark", + ]) + for r in rows or []: + writer.writerow([ + r.object_name or "", + r.customer_name or "", + r.job_id or "", + r.job_name or "", + r.backup_software or "", + r.backup_type or "", + r.run_id or "", + r.run_at.isoformat() if r.run_at else "", + r.status or "", + "1" if r.missed else "0", + "1" if r.override_applied else "0", + r.reviewed_at.isoformat() if r.reviewed_at else "", + r.ticket_number or "", + (r.remark or "").replace("\r", " ").replace("\n", " ").strip(), + ]) + filename = f"report-{report_id}-snapshot.csv" + + csv_bytes = output.getvalue().encode("utf-8") + mem = io.BytesIO(csv_bytes) + mem.seek(0) + return send_file(mem, mimetype="text/csv", as_attachment=True, download_name=filename) + + +def _export_html_response(report: ReportDefinition, report_id: int, view: str): + stats = _build_report_stats_payload(report_id) + summary = stats.get("summary") or {} + charts = stats.get("charts") or {} + trends = charts.get("trends") or [] + status_dist = charts.get("status_distribution") or {} + perf = charts.get("performance") or [] + + def _esc(s: str) -> str: + return (s or "").replace("&", "&").replace("<", "<").replace(">", ">") + + snapshot_table_html = "" + if (view or "summary").strip().lower() == "snapshot": + snap_rows = [] + with db.engine.connect() as conn: + rows = conn.execute( + text( + """ + SELECT + COALESCE(object_name,'') AS object_name, + COALESCE(customer_name,'') AS customer_name, + COALESCE(job_name,'') AS job_name, + COALESCE(backup_software,'') AS backup_software, + COALESCE(backup_type,'') AS backup_type, + run_at, + COALESCE(status,'') AS status, + missed, + override_applied, + COALESCE(ticket_number,'') AS ticket_number, + COALESCE(remark,'') AS remark + FROM report_object_snapshots + WHERE report_id = :rid + ORDER BY customer_name ASC, object_name ASC + LIMIT 500 + """ + ), + {"rid": report_id}, + ).fetchall() + + for r in rows or []: + snap_rows.append( + { + "object_name": r.object_name or "", + "customer_name": r.customer_name or "", + "job_name": r.job_name or "", + "backup_software": r.backup_software or "", + "backup_type": r.backup_type or "", + "run_at": r.run_at.isoformat() if r.run_at else "", + "status": r.status or "", + "missed": bool(r.missed), + "override_applied": bool(r.override_applied), + "ticket_number": r.ticket_number or "", + "remark": (r.remark or "").replace("\r", " ").replace("\n", " ").strip(), + } + ) + + row_html = [] + for s in snap_rows: + row_html.append( + "" + f"{_esc(s['object_name'])}" + f"{_esc(s['customer_name'])}" + f"{_esc(s['job_name'])}" + f"{_esc(s['backup_software'])}" + f"{_esc(s['backup_type'])}" + f"{_esc(s['run_at'])}" + f"{_esc(s['status'])}" + f"{'1' if s['missed'] else '0'}" + f"{'1' if s['override_applied'] else '0'}" + f"{_esc(s['ticket_number'])}" + f"{_esc(s['remark'])}" + "" + ) + + snapshot_table_html = """
+
+
+
+
Snapshot preview
+
First 500 rows
+
+
+
+ + + + + + + + + + + + + + + + + +""" + "\n".join(row_html) + """ +
ObjectCustomerJobSoftwareTypeRun atStatusMissedOverrideTicketRemark
+
+
+
+
+
""" + + import json as _json + + title = _esc(report.name or "Report") + period_s = report.period_start.isoformat() if report.period_start else "—" + period_e = report.period_end.isoformat() if report.period_end else "—" + + perf_rows = [] + for p in perf: + perf_rows.append( + "" + f"{_esc(p.get('customer_name') or '')}" + f"{int(p.get('total_runs') or 0)}" + f"{int(p.get('success_count') or 0)}" + f"{int(p.get('warning_count') or 0)}" + f"{int(p.get('failed_count') or 0)}" + f"{int(p.get('missed_count') or 0)}" + f"{p.get('success_rate', 0)}%" + "" + ) + + html = f""" + + + + + {title} + + + + + +
+
+
+

{title}

+
Report ID {report_id} · Period {period_s} → {period_e}
+
+
+
Generated HTML preview
+
+
+ +
+
+
+
Total runs
+
{summary.get('total_runs', 0)}
+
+
+
+
+
Success
+
{summary.get('success_count', 0)}
+
+
+
+
+
Failed
+
{summary.get('failed_count', 0)}
+
+
+
+
+
Success rate
+
{summary.get('success_rate', 0)}%
+
+
+
+ +
+
+
+
+
Success rate trend
+
Daily success rate based on generated snapshots
+
+
+ +
+
+
+
+
+
+
Status distribution
+
All snapshots grouped by status
+
+
+ +
+
+
+
+ +
+
+
+
+
Performance by customer
+
Aggregated from generated snapshot data
+
+
+
+ + + + + + + + + + + + + + {''.join(perf_rows)} + +
CustomerTotalSuccessWarningFailedMissedSuccess %
+
+
+
+
+
+ + {snapshot_table_html} + +
+ + + + +""" + + resp = Response(html, mimetype="text/html") + resp.headers["Content-Disposition"] = f'inline; filename="report-{report_id}.html"' + return resp + + +def _export_pdf_response(report: ReportDefinition, report_id: int, view: str): + stats = _build_report_stats_payload(report_id) + summary = stats.get("summary") or {} + trends = (stats.get("charts") or {}).get("trends") or [] + + from reportlab.pdfgen import canvas + from reportlab.lib.pagesizes import A4 + from reportlab.lib.units import mm + + buf = io.BytesIO() + c = canvas.Canvas(buf, pagesize=A4) + w, h = A4 + + margin = 18 * mm + y = h - margin + + c.setTitle(report.name or f"Report {report_id}") + + c.setFont("Helvetica-Bold", 16) + c.drawString(margin, y, report.name or "Report") + y -= 8 * mm + + c.setFont("Helvetica", 9) + period_s = report.period_start.isoformat() if report.period_start else "—" + period_e = report.period_end.isoformat() if report.period_end else "—" + c.drawString(margin, y, f"Report ID {report_id} Period {period_s} → {period_e}") + y -= 10 * mm + + c.setFont("Helvetica-Bold", 11) + c.drawString(margin, y, "Summary") + y -= 6 * mm + + c.setFont("Helvetica", 10) + summary_lines = [ + f"Total runs: {summary.get('total_runs', 0)}", + f"Success: {summary.get('success_count', 0)}", + f"Warning: {summary.get('warning_count', 0)}", + f"Failed: {summary.get('failed_count', 0)}", + f"Missed: {summary.get('missed_count', 0)}", + f"Success rate: {summary.get('success_rate', 0)}%", + ] + for ln in summary_lines: + c.drawString(margin, y, ln) + y -= 5 * mm + + y -= 4 * mm + + c.setFont("Helvetica-Bold", 11) + c.drawString(margin, y, "Success rate trend") + y -= 6 * mm + + chart_w = w - (2 * margin) + chart_h = 55 * mm + chart_x = margin + chart_y = y - chart_h + + c.setLineWidth(1) + c.rect(chart_x, chart_y, chart_w, chart_h) + + if trends: + points = [] + n = len(trends) + for i, t in enumerate(trends): + try: + rate = float(t.get("success_rate") or 0.0) + except Exception: + rate = 0.0 + x = chart_x + (i * (chart_w / max(1, n - 1))) + yv = chart_y + (rate / 100.0) * chart_h + points.append((x, yv)) + + c.setLineWidth(1.5) + for i in range(1, len(points)): + c.line(points[i - 1][0], points[i - 1][1], points[i][0], points[i][1]) + + c.setFont("Helvetica", 8) + first_day = (trends[0].get("day") or "")[:10] + last_day = (trends[-1].get("day") or "")[:10] + c.drawString(chart_x, chart_y - 4 * mm, first_day) + c.drawRightString(chart_x + chart_w, chart_y - 4 * mm, last_day) + c.drawRightString(chart_x - 2, chart_y, "0%") + c.drawRightString(chart_x - 2, chart_y + chart_h, "100%") + else: + c.setFont("Helvetica", 9) + c.drawString(chart_x + 4 * mm, chart_y + chart_h / 2, "No trend data available (generate the report first).") + + c.showPage() + c.save() + + buf.seek(0) + return send_file(buf, mimetype="application/pdf", as_attachment=True, download_name=f"report-{report_id}.pdf") + + +@main_bp.route("/api/reports//export", methods=["GET"]) +@login_required +def api_reports_export(report_id: int): err = _require_reporting_role() if err is not None: return err report = ReportDefinition.query.get_or_404(report_id) view = (request.args.get("view") or "summary").strip().lower() + fmt = (request.args.get("format") or report.output_format or "csv").strip().lower() - if view not in ("summary", "snapshot"): - view = "summary" + if fmt not in ("csv", "html", "pdf"): + fmt = "csv" - output = io.StringIO() - writer = csv.writer(output) + if fmt == "csv": + return _export_csv_response(report, report_id, view) - if view == "summary": - writer.writerow([ - "object_name", - "customer_id", - "customer_name", - "total_runs", - "success_count", - "success_override_count", - "warning_count", - "failed_count", - "missed_count", - "success_rate", - ]) - rows = ( - db.session.query(ReportObjectSummary) - .filter(ReportObjectSummary.report_id == report_id) - .order_by(db.func.coalesce(ReportObjectSummary.customer_name, '').asc(), ReportObjectSummary.object_name.asc()) - .all() - ) - for r in rows: - writer.writerow([ - r.object_name or "", - r.customer_id or "", - r.customer_name or "", - int(r.total_runs or 0), - int(r.success_count or 0), - int(r.success_override_count or 0), - int(r.warning_count or 0), - int(r.failed_count or 0), - int(r.missed_count or 0), - round(float(r.success_rate or 0.0), 2), - ]) - filename = f"report-{report_id}-summary.csv" - else: - writer.writerow([ - "object_name", - "customer_id", - "customer_name", - "job_id", - "job_name", - "backup_software", - "backup_type", - "run_id", - "run_at", - "status", - "missed", - "override_applied", - "reviewed_at", - "ticket_number", - "remark", - ]) - rows = ( - db.session.query(ReportObjectSnapshot) - .filter(ReportObjectSnapshot.report_id == report_id) - .order_by(ReportObjectSnapshot.object_name.asc(), ReportObjectSnapshot.run_at.asc()) - .all() - ) - for r in rows: - writer.writerow([ - r.object_name or "", - r.customer_id or "", - r.customer_name or "", - r.job_id or "", - r.job_name or "", - r.backup_software or "", - r.backup_type or "", - r.run_id or "", - r.run_at.isoformat() if r.run_at else "", - r.status or "", - "1" if r.missed else "0", - "1" if r.override_applied else "0", - r.reviewed_at.isoformat() if r.reviewed_at else "", - r.ticket_number or "", - (r.remark or "").replace("\r", " ").replace("\n", " ").strip(), - ]) - filename = f"report-{report_id}-snapshot.csv" + if fmt == "html": + return _export_html_response(report, report_id, view) - csv_bytes = output.getvalue().encode("utf-8") - mem = io.BytesIO(csv_bytes) - mem.seek(0) - return send_file(mem, mimetype="text/csv", as_attachment=True, download_name=filename) + return _export_pdf_response(report, report_id, view) + + +@main_bp.route("/api/reports//export.csv", methods=["GET"]) +@login_required +def api_reports_export_csv(report_id: int): + # Backward compatible route: always returns CSV. + err = _require_reporting_role() + if err is not None: + return err + + report = ReportDefinition.query.get_or_404(report_id) + view = (request.args.get("view") or "summary").strip().lower() + return _export_csv_response(report, report_id, view) diff --git a/containers/backupchecks/src/templates/main/reports.html b/containers/backupchecks/src/templates/main/reports.html index 95f42fe..5c23126 100644 --- a/containers/backupchecks/src/templates/main/reports.html +++ b/containers/backupchecks/src/templates/main/reports.html @@ -53,7 +53,7 @@ Edit - Download + Download {% if active_role in ('admin','operator','reporter') %} {% endif %} @@ -281,7 +281,7 @@ return; } btn.classList.remove('disabled'); - btn.setAttribute('href', '/api/reports/' + rawReportId + '/export.csv?view=' + rawView); + btn.setAttribute('href', '/api/reports/' + rawReportId + '/export?view=' + rawView); } function updateRawMeta(total) { @@ -394,7 +394,7 @@ function loadRawData() { 'Edit' + '' + '' + - 'Download' + + 'Download' + (canDeleteReports ? '' : '') + '';; diff --git a/containers/backupchecks/src/templates/main/reports_new.html b/containers/backupchecks/src/templates/main/reports_new.html index 624d953..b29388d 100644 --- a/containers/backupchecks/src/templates/main/reports_new.html +++ b/containers/backupchecks/src/templates/main/reports_new.html @@ -30,8 +30,9 @@
diff --git a/docs/changelog.md b/docs/changelog.md index 3dd3919..9a9b23a 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -161,6 +161,17 @@ - Updated the report selection UI to make the “Backup software” and “Backup type” select boxes equal in size. - Both select boxes are now displayed with a fixed height of 6 visible rows for consistent layout and improved usability. +--- + +## v20260103-19-reports-output-format-html + +- Added "HTML" as an output format option next to CSV and PDF in the report definition form. +- Enabled generic report export to support CSV, HTML and PDF outputs. +- Implemented an HTML export preview with a graphical layout (summary cards + charts) to validate report styling. +- Implemented a basic PDF export with summary and a simple success-rate trend chart. +- Updated the Reports overview to use the new generic export endpoint (no longer hardcoded to export.csv). +- Added ReportLab dependency for PDF generation. + ================================================================================================================================================