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>")
|
@app.route("/reports/download/<filename>")
|
||||||
def blancco_download_report(filename):
|
def blancco_download_report(filename):
|
||||||
filename = secure_filename(filename)
|
filename = secure_filename(filename)
|
||||||
|
|||||||
@@ -103,3 +103,69 @@ def parse(path: str) -> dict:
|
|||||||
out["hardware"][sub.attrib.get("name", "")] = _walk(sub)
|
out["hardware"][sub.attrib.get("name", "")] = _walk(sub)
|
||||||
|
|
||||||
return out
|
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>
|
</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-card">
|
||||||
<div class="section-title">Drive Erasure Certificates</div>
|
<div class="section-title">Drive Erasure Certificates</div>
|
||||||
{% if reports %}
|
{% if reports %}
|
||||||
|
|||||||
Reference in New Issue
Block a user