From 96c4e5dd11cd94d3d48ebf2f37607684cf742d5a Mon Sep 17 00:00:00 2001 From: cproudlock Date: Mon, 5 Oct 2026 09:32:32 -0400 Subject: [PATCH] webapp: export Blancco reports for the last 1/3/6/12 months /reports/export?range=1m|3m|6m|12m&format=csv|zip. CSV has one row per erased drive (system and drive serials, standard, result, times, licence consumption id). ZIP holds the original XML/PDF reports plus summary.csv. Reports are selected by the date inside each XML, falling back to mtime only when it is missing. Exports are audit-logged. --- webapp/app.py | 73 +++++++++++++++++++++++++++++++ webapp/services/blancco_report.py | 66 ++++++++++++++++++++++++++++ webapp/templates/reports.html | 20 +++++++++ 3 files changed, 159 insertions(+) diff --git a/webapp/app.py b/webapp/app.py index b2b10ed..e9eb6cf 100644 --- a/webapp/app.py +++ b/webapp/app.py @@ -591,6 +591,79 @@ def blancco_reports(): ) +REPORT_EXPORT_RANGES = {"1m": 1, "3m": 3, "6m": 6, "12m": 12} + + +def _months_ago(now, months): + # Same day-of-month N months back, clamped (Mar 31 - 1m -> Feb 28/29). + import calendar + y, m = divmod(now.year * 12 + now.month - 1 - months, 12) + day = min(now.day, calendar.monthrange(y, m + 1)[1]) + return now.replace(year=y, month=m + 1, day=day) + + +@app.route("/reports/export") +def blancco_export_reports(): + import csv + import io + import zipfile + from datetime import timezone + + rng = request.args.get("range", "1m") + fmt = request.args.get("format", "csv") + if rng not in REPORT_EXPORT_RANGES or fmt not in ("csv", "zip"): + abort(400) + now = datetime.now(timezone.utc) + since = _months_ago(now, REPORT_EXPORT_RANGES[rng]) + + # Select by the date inside each XML report; fall back to mtime only + # when a report has no parseable date. PDFs (7.20 writes pdf+xml) ride + # along with the XML of the same name. + rows, files = [], [] + folder = config.BLANCCO_REPORTS + names = sorted(os.listdir(folder)) if os.path.isdir(folder) else [] + for f in names: + fpath = os.path.join(folder, f) + if not (f.lower().endswith(".xml") and os.path.isfile(fpath)): + continue + try: + data = blancco_report.parse(fpath) + except Exception: + data = {} + when = blancco_report.report_datetime(data) or datetime.fromtimestamp( + os.stat(fpath).st_mtime, timezone.utc) + if when < since: + continue + rows.extend(blancco_report.export_rows(f, data, when.isoformat())) + files.append(f) + pdf = os.path.splitext(f)[0] + ".pdf" + if pdf in names: + files.append(pdf) + + buf = io.StringIO() + writer = csv.DictWriter(buf, fieldnames=blancco_report.EXPORT_COLUMNS) + writer.writeheader() + writer.writerows(rows) + stem = f"blancco-reports-{rng}-{now:%Y%m%d}" + audit("REPORT_EXPORT", f"{rng} {fmt} {len(files)} files") + + if fmt == "csv": + return Response( + buf.getvalue(), + mimetype="text/csv", + headers={"Content-Disposition": f"attachment; filename={stem}.csv"}, + ) + # A year of reports can run to hundreds of MB; spill to disk past 64MB. + out = tempfile.SpooledTemporaryFile(max_size=64 * 1024 * 1024) + with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr(f"{stem}/summary.csv", buf.getvalue()) + for f in files: + zf.write(os.path.join(folder, f), f"{stem}/{f}") + out.seek(0) + return send_file(out, mimetype="application/zip", as_attachment=True, + download_name=f"{stem}.zip") + + @app.route("/reports/download/") def blancco_download_report(filename): filename = secure_filename(filename) diff --git a/webapp/services/blancco_report.py b/webapp/services/blancco_report.py index e3ab4e8..070a38f 100644 --- a/webapp/services/blancco_report.py +++ b/webapp/services/blancco_report.py @@ -103,3 +103,69 @@ def parse(path: str) -> dict: out["hardware"][sub.attrib.get("name", "")] = _walk(sub) return out + + +def report_datetime(data: dict): + """Report creation time from the report itself, or None if missing. + + Blancco writes ISO 8601 with a numeric offset, e.g. + 2026-09-04T17:00:22+0000. File mtime is not reliable for audit ranges: + copying reports onto the share resets it. + """ + from datetime import datetime + raw = (data.get("meta") or {}).get("date") or "" + try: + return datetime.strptime(raw, "%Y-%m-%dT%H:%M:%S%z") + except ValueError: + return None + + +EXPORT_COLUMNS = [ + "report_file", "report_date", "document_id", "product_version", + "system_manufacturer", "system_model", "system_serial", + "drive_model", "drive_serial", "drive_capacity_gb", "drive_interface", + "erasure_standard", "state", "start_time", "end_time", "elapsed_time", + "total_errors", "license_consumption_id", "business_name", +] + + +def export_rows(filename: str, data: dict, when: str) -> list[dict]: + """One CSV row per erased drive; a report with no erasures still gets a row.""" + meta = data.get("meta") or {} + system = (data.get("hardware") or {}).get("system") or {} + if isinstance(system, list): + system = system[0] if system else {} + base = { + "report_file": filename, + "report_date": when, + "document_id": meta.get("document_id", ""), + "product_version": meta.get("product_version", ""), + "system_manufacturer": system.get("manufacturer", ""), + "system_model": system.get("model", ""), + "system_serial": system.get("serial", ""), + "license_consumption_id": ";".join(data.get("license_consumption_ids") or []), + "business_name": (data.get("company") or {}).get("business_name", ""), + } + rows = [] + for er in data.get("erasures") or []: + target = er.get("target") or {} + if isinstance(target, list): + target = target[0] if target else {} + try: + cap = f"{int(target.get('capacity', '')) / 1e9:.1f}" + except ValueError: + cap = "" + rows.append({ + **base, + "drive_model": target.get("model", ""), + "drive_serial": target.get("serial", ""), + "drive_capacity_gb": cap, + "drive_interface": target.get("interface_type", ""), + "erasure_standard": er.get("erasure_standard_name", ""), + "state": er.get("state", ""), + "start_time": er.get("start_time", ""), + "end_time": er.get("end_time", ""), + "elapsed_time": er.get("elapsed_time", ""), + "total_errors": er.get("total_errors", ""), + }) + return rows or [base] diff --git a/webapp/templates/reports.html b/webapp/templates/reports.html index 5c90f5f..0e47eae 100644 --- a/webapp/templates/reports.html +++ b/webapp/templates/reports.html @@ -9,6 +9,26 @@ +
+
Export Reports
+
+ + + + +
+

Dated by the timestamp inside each report, not the file date.

+
+
Drive Erasure Certificates
{% if reports %}