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:
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user