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.
This commit is contained in:
cproudlock
2026-10-05 09:32:32 -04:00
parent 678d10d5c2
commit 96c4e5dd11
3 changed files with 159 additions and 0 deletions

View File

@@ -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/<filename>")
def blancco_download_report(filename):
filename = secure_filename(filename)