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)

View File

@@ -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]

View File

@@ -9,6 +9,26 @@
</div>
</div>
<div class="section-card">
<div class="section-title">Export Reports</div>
<form method="get" action="{{ url_for('blancco_export_reports') }}" class="d-flex flex-wrap align-items-center gap-2">
<label for="exportRange" class="mb-0">Reports from the last</label>
<select id="exportRange" name="range" class="form-select form-select-sm w-auto">
<option value="1m">1 month</option>
<option value="3m">3 months</option>
<option value="6m">6 months</option>
<option value="12m">12 months</option>
</select>
<button type="submit" name="format" value="csv" class="pxe-btn pxe-btn-secondary" title="One row per erased drive">
<i class="bi bi-filetype-csv"></i> Summary CSV
</button>
<button type="submit" name="format" value="zip" class="pxe-btn pxe-btn-secondary" title="Original XML/PDF reports plus summary.csv">
<i class="bi bi-file-zip"></i> Reports ZIP
</button>
</form>
<p class="small text-muted mt-2 mb-0">Dated by the timestamp inside each report, not the file date.</p>
</div>
<div class="section-card">
<div class="section-title">Drive Erasure Certificates</div>
{% if reports %}