Files
pxe-server/webapp/services/blancco_report.py
cproudlock 96c4e5dd11 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.
2026-10-05 09:32:32 -04:00

172 lines
6.3 KiB
Python

"""
Parse Blancco Drive Eraser XML report into a friendly Python dict.
Blancco encodes everything as <entries name="X"><entry name="K" type="T">V</entry>...
which is too noisy to read directly in a template. Walking the tree once into
nested dicts/lists lets the template render fields by their semantic name
without any XPath-style noise.
Returned shape (best-effort; missing keys are OK in templates):
{
"meta": {document_id, date, product_name, product_version,
product_revision, integrity},
"company": {business_location, business_name, customer_license, ...},
"license_consumption_ids": [str, ...],
"erasures": [ {erasure_id, timestamp, target:{...}, state,
elapsed_time, erasure_standard_name, step:[...], ...} ],
"hardware": {system:{...}, bios:{...}, processors:{...}, ...},
}
"""
from __future__ import annotations
import xml.etree.ElementTree as ET
def _walk(node):
# Recursive walk over Blancco's <entry|entries name="K"> children.
# Repeated names collapse to a list.
result = {}
for child in node:
name = child.attrib.get("name") or child.tag
if child.tag == "entry":
val = (child.text or "").strip()
elif child.tag == "entries":
val = _walk(child)
else:
continue
if name in result:
existing = result[name]
if not isinstance(existing, list):
result[name] = [existing]
result[name].append(val)
else:
result[name] = val
return result
def _text(parent, path, default=""):
if parent is None:
return default
el = parent.find(path)
if el is None or el.text is None:
return default
return el.text.strip()
def parse(path: str) -> dict:
tree = ET.parse(path)
root = tree.getroot()
rep = root.find(".//report/blancco_data") or root
out = {
"meta": {},
"company": {},
"license_consumption_ids": [],
"erasures": [],
"hardware": {},
}
desc = rep.find("description")
if desc is not None:
out["meta"]["document_id"] = _text(desc, "document_id")
log_entry = desc.find(".//log_entry")
if log_entry is not None:
author = log_entry.find("author")
if author is not None:
out["meta"]["product_name"] = _text(author, "product_name")
out["meta"]["product_version"] = _text(author, "product_version")
out["meta"]["product_revision"] = _text(author, "product_revision")
out["meta"]["date"] = _text(log_entry, "date")
out["meta"]["integrity"] = _text(log_entry, "integrity")
company = desc.find(".//entry[@name='description_entries']/entries[@name='company_information']")
if company is not None:
for e in company.findall("entry"):
k = e.attrib.get("name", "")
if k:
out["company"][k] = (e.text or "").strip()
lcids = desc.find(".//entry[@name='description_entries']/entries[@name='license_consumption_ids']")
if lcids is not None:
for e in lcids.findall("entry"):
v = (e.text or "").strip()
if v:
out["license_consumption_ids"].append(v)
for er in rep.findall(".//blancco_erasure_report//entries[@name='erasure']"):
out["erasures"].append(_walk(er))
hw = rep.find("blancco_erasure_report/../blancco_hardware_report") or rep.find(".//blancco_hardware_report")
if hw is not None:
for sub in hw:
if sub.tag != "entries":
continue
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]