blancco: CMMC evidence pack, ServiceNow tick list, cloud-upload status
Automates the Blancco parts of the onsite-support CMMC playbook:
- ENT-056/158 evidence pack: /reports/evidence?month=YYYY-MM returns
ENT-056_<Site>_<YYYY-MM>.zip (summary.csv, README, each report's XML
and PDF). pxe-blancco-evidence.timer saves last month's pack to the
reports share's evidence/ folder on the 1st (Persistent).
- ENT-045/159 ServiceNow tick list: /reports/servicenow lists recent
reports as "Wiped" or "Blancco Wipe failed" with a pre-written
destruction note, copy buttons and Done (CSRF-protected).
- Cloud upload: blancco-progress.py posts each report's upload result
("Sending report document id" -> "Report sent to MC") to
/reports/cloud-status (CSRF-exempt like /imaging/status); the Reports
page and the CSVs show it, so a wipe missing from BMP is visible.
- CSV gains failure_reason (exception messages, failed steps, error
counts), business_location and cloud_upload.
- Preferences: business_location "West Jefferson" (config.BLANCCO_SITE)
and pdf+xml reports; the report copier now copies the PDF too, named
after its XML. .part and hidden files are not listed as reports.
State (cloud confirmations, ServiceNow ticks) is a JSON file at
/var/lib/pxe-webapp/blancco-state.json, off the report share.
This commit is contained in:
@@ -40,6 +40,7 @@ from werkzeug.utils import secure_filename
|
||||
|
||||
import config
|
||||
from services import (
|
||||
blancco_evidence,
|
||||
blancco_report,
|
||||
deploy,
|
||||
fs,
|
||||
@@ -541,10 +542,12 @@ def clonezilla_delete(filename):
|
||||
@app.route("/reports")
|
||||
def blancco_reports():
|
||||
reports = []
|
||||
state = blancco_evidence.load_state()
|
||||
if os.path.isdir(config.BLANCCO_REPORTS):
|
||||
for f in sorted(os.listdir(config.BLANCCO_REPORTS), reverse=True):
|
||||
fpath = os.path.join(config.BLANCCO_REPORTS, f)
|
||||
if not os.path.isfile(fpath):
|
||||
# .part files are reports still being copied in by Blancco.
|
||||
if not os.path.isfile(fpath) or f.startswith(".") or f.endswith(".part"):
|
||||
continue
|
||||
stat = os.stat(fpath)
|
||||
ext = os.path.splitext(f)[1].lower()
|
||||
@@ -554,7 +557,8 @@ def blancco_reports():
|
||||
# negligible at fleet sizes).
|
||||
serial = ""
|
||||
model = ""
|
||||
state = ""
|
||||
result = ""
|
||||
cloud = ""
|
||||
if ext == ".xml":
|
||||
try:
|
||||
data = blancco_report.parse(fpath)
|
||||
@@ -567,11 +571,13 @@ def blancco_reports():
|
||||
erasures = data.get("erasures") or []
|
||||
states = [(e.get("state") or "").strip() for e in erasures if e.get("state")]
|
||||
if not states:
|
||||
state = ""
|
||||
result = ""
|
||||
elif any(s.lower() != "successful" for s in states):
|
||||
state = "Failed"
|
||||
result = "Failed"
|
||||
else:
|
||||
state = "Successful"
|
||||
result = "Successful"
|
||||
cloud = blancco_evidence.cloud_status(
|
||||
state, (data.get("meta") or {}).get("document_id", ""))
|
||||
except Exception:
|
||||
pass
|
||||
reports.append({
|
||||
@@ -581,11 +587,14 @@ def blancco_reports():
|
||||
"type": ext.lstrip(".").upper() or "FILE",
|
||||
"serial": serial,
|
||||
"model": model,
|
||||
"state": state,
|
||||
"state": result,
|
||||
"cloud": cloud,
|
||||
})
|
||||
return render_template(
|
||||
"reports.html",
|
||||
reports=reports,
|
||||
site=config.BLANCCO_SITE,
|
||||
last_month=blancco_evidence.previous_month(),
|
||||
image_types=config.IMAGE_TYPES,
|
||||
friendly_names=config.FRIENDLY_NAMES,
|
||||
)
|
||||
@@ -620,6 +629,7 @@ def blancco_export_reports():
|
||||
# when a report has no parseable date. PDFs (7.20 writes pdf+xml) ride
|
||||
# along with the XML of the same name.
|
||||
rows, files = [], []
|
||||
cloud_state = blancco_evidence.load_state()
|
||||
folder = config.BLANCCO_REPORTS
|
||||
names = sorted(os.listdir(folder)) if os.path.isdir(folder) else []
|
||||
for f in names:
|
||||
@@ -634,7 +644,9 @@ def blancco_export_reports():
|
||||
os.stat(fpath).st_mtime, timezone.utc)
|
||||
if when < since:
|
||||
continue
|
||||
rows.extend(blancco_report.export_rows(f, data, when.isoformat()))
|
||||
rows.extend(blancco_report.export_rows(
|
||||
f, data, when.isoformat(),
|
||||
blancco_evidence.cloud_status(cloud_state, (data.get("meta") or {}).get("document_id", ""))))
|
||||
files.append(f)
|
||||
pdf = os.path.splitext(f)[0] + ".pdf"
|
||||
if pdf in names:
|
||||
@@ -664,6 +676,54 @@ def blancco_export_reports():
|
||||
download_name=f"{stem}.zip")
|
||||
|
||||
|
||||
@app.route("/reports/cloud-status", methods=["POST"])
|
||||
def blancco_cloud_status():
|
||||
# CSRF-exempt machine-to-machine endpoint: blancco-progress.py, running
|
||||
# inside Blancco, reports whether each report reached the Blancco cloud.
|
||||
p = request.get_json(silent=True) or {}
|
||||
try:
|
||||
blancco_evidence.record_cloud(
|
||||
p.get("document_id", ""), bool(p.get("sent")), p.get("serial", ""), p.get("detail", ""))
|
||||
except ValueError as ex:
|
||||
return jsonify({"error": str(ex)}), 400
|
||||
return jsonify({"ok": True}), 200
|
||||
|
||||
|
||||
@app.route("/reports/evidence")
|
||||
def blancco_evidence_pack():
|
||||
"""CMMC evidence pack (ENT-056/158): one ZIP per site and month."""
|
||||
month = request.args.get("month") or blancco_evidence.previous_month()
|
||||
if not re.fullmatch(r"20\d\d-(0[1-9]|1[0-2])", month):
|
||||
abort(400)
|
||||
out = tempfile.SpooledTemporaryFile(max_size=64 * 1024 * 1024)
|
||||
count = blancco_evidence.build_evidence_zip(month, out)
|
||||
out.seek(0)
|
||||
audit("REPORT_EVIDENCE", f"{month} {count} reports")
|
||||
return send_file(out, mimetype="application/zip", as_attachment=True,
|
||||
download_name=blancco_evidence.evidence_name(month) + ".zip")
|
||||
|
||||
|
||||
@app.route("/reports/servicenow")
|
||||
def blancco_servicenow():
|
||||
"""What to tick on each asset's Onsite Bin Management RITM (ENT-045/159)."""
|
||||
include_done = request.args.get("all") == "1"
|
||||
days = request.args.get("days", 30, type=int)
|
||||
items = blancco_evidence.servicenow_items(days=max(1, min(days, 366)), include_done=include_done)
|
||||
return render_template("reports_servicenow.html", items=items,
|
||||
include_done=include_done, days=days)
|
||||
|
||||
|
||||
@app.route("/reports/servicenow/<document_id>", methods=["POST"])
|
||||
def blancco_servicenow_mark(document_id):
|
||||
done = request.form.get("done", "1") == "1"
|
||||
try:
|
||||
blancco_evidence.mark_servicenow(document_id, done)
|
||||
except ValueError:
|
||||
abort(400)
|
||||
audit("REPORT_SERVICENOW", f"{document_id} {'done' if done else 'undone'}")
|
||||
return redirect(url_for("blancco_servicenow", all=request.args.get("all")))
|
||||
|
||||
|
||||
@app.route("/reports/download/<filename>")
|
||||
def blancco_download_report(filename):
|
||||
filename = secure_filename(filename)
|
||||
|
||||
36
webapp/blancco_evidence_monthly.py
Normal file
36
webapp/blancco_evidence_monthly.py
Normal file
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Save the monthly Blancco CMMC evidence pack to the report share.
|
||||
|
||||
Run by pxe-blancco-evidence.timer on the 1st of each month: writes
|
||||
<BLANCCO_REPORTS>/evidence/ENT-056_<Site>_<YYYY-MM>.zip for the previous
|
||||
month (or --month YYYY-MM), so the tech only has to copy it into the
|
||||
site's Box evidence folder. Same pack as the webapp's /reports/evidence.
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import config # noqa: E402
|
||||
from services import blancco_evidence # noqa: E402
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
ap.add_argument("--month", default=blancco_evidence.previous_month(),
|
||||
help="YYYY-MM, default: previous month")
|
||||
args = ap.parse_args()
|
||||
blancco_evidence.month_bounds(args.month) # validates the format
|
||||
out_dir = os.path.join(config.BLANCCO_REPORTS, "evidence")
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
final = os.path.join(out_dir, blancco_evidence.evidence_name(args.month) + ".zip")
|
||||
tmp = final + ".part"
|
||||
with open(tmp, "wb") as f:
|
||||
count = blancco_evidence.build_evidence_zip(args.month, f)
|
||||
os.replace(tmp, final)
|
||||
print(f"{final}: {count} report(s)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -21,6 +21,11 @@ WEB_ROOT = os.environ.get("WEB_ROOT", "/var/www/html")
|
||||
BOOT_WIM = os.path.join(WEB_ROOT, "win11", "sources", "boot.wim")
|
||||
AUDIT_LOG = os.environ.get("AUDIT_LOG", "/var/log/pxe-webapp-audit.log")
|
||||
IMAGING_DIR = os.environ.get("IMAGING_DIR", "/var/log/pxe-imaging")
|
||||
# Blancco evidence: site name stamped on reports and evidence packs, and a
|
||||
# small JSON state file (cloud-upload confirmations, ServiceNow ticks) kept
|
||||
# off the report share so it never shows up as a report.
|
||||
BLANCCO_SITE = os.environ.get("BLANCCO_SITE", "West Jefferson")
|
||||
BLANCCO_STATE = os.environ.get("BLANCCO_STATE", "/var/lib/pxe-webapp/blancco-state.json")
|
||||
|
||||
# Log sources used by services/imaging_log_tail.py to infer client progress
|
||||
# when no /imaging/status push has arrived yet. Override per-host if any of
|
||||
|
||||
215
webapp/services/blancco_evidence.py
Normal file
215
webapp/services/blancco_evidence.py
Normal file
@@ -0,0 +1,215 @@
|
||||
"""CMMC evidence helpers for Blancco erasure reports.
|
||||
|
||||
The onsite-support playbook asks techs for, among others:
|
||||
ENT-045 / 159 list of sanitized media + failure cause (ServiceNow
|
||||
Onsite Bin RITM "Wiped" / "Blancco Wipe failed" boxes)
|
||||
ENT-056 / 158 sample Blancco erasure certificates per site, monthly
|
||||
|
||||
This module backs the webapp's evidence pack (one ZIP per site and month:
|
||||
summary.csv plus each report's XML and PDF), the ServiceNow tick list,
|
||||
and the record of which reports reached the Blancco cloud (posted by
|
||||
blancco-progress.py running inside Blancco).
|
||||
|
||||
State lives in one small JSON file (config.BLANCCO_STATE), not on the report
|
||||
share, written atomically:
|
||||
{"cloud": {document_id: {"sent": bool, "ts", "serial", "detail"}},
|
||||
"servicenow": {document_id: {"ts"}}}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import zipfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import config
|
||||
from services import blancco_report
|
||||
|
||||
_DOC_ID = re.compile(r"^[0-9a-fA-F-]{36}$")
|
||||
|
||||
|
||||
# --- state ------------------------------------------------------------------
|
||||
|
||||
def load_state() -> dict:
|
||||
try:
|
||||
with open(config.BLANCCO_STATE) as f:
|
||||
state = json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
state = {}
|
||||
state.setdefault("cloud", {})
|
||||
state.setdefault("servicenow", {})
|
||||
return state
|
||||
|
||||
|
||||
def _save_state(state: dict):
|
||||
folder = os.path.dirname(config.BLANCCO_STATE)
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=folder, prefix=".blancco-state-")
|
||||
with os.fdopen(fd, "w") as f:
|
||||
json.dump(state, f, indent=1, sort_keys=True)
|
||||
os.replace(tmp, config.BLANCCO_STATE)
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def record_cloud(document_id: str, sent: bool, serial: str = "", detail: str = ""):
|
||||
if not _DOC_ID.match(document_id or ""):
|
||||
raise ValueError("bad document_id")
|
||||
state = load_state()
|
||||
state["cloud"][document_id] = {
|
||||
"sent": bool(sent), "ts": _now(),
|
||||
"serial": (serial or "")[:64], "detail": (detail or "")[:200],
|
||||
}
|
||||
_save_state(state)
|
||||
|
||||
|
||||
def mark_servicenow(document_id: str, done: bool = True):
|
||||
if not _DOC_ID.match(document_id or ""):
|
||||
raise ValueError("bad document_id")
|
||||
state = load_state()
|
||||
if done:
|
||||
state["servicenow"][document_id] = {"ts": _now()}
|
||||
else:
|
||||
state["servicenow"].pop(document_id, None)
|
||||
_save_state(state)
|
||||
|
||||
|
||||
def cloud_status(state: dict, document_id: str) -> str:
|
||||
""""sent", "failed", or "" when Blancco never told us."""
|
||||
rec = state["cloud"].get(document_id)
|
||||
if not rec:
|
||||
return ""
|
||||
return "sent" if rec.get("sent") else "failed"
|
||||
|
||||
|
||||
# --- reports ----------------------------------------------------------------
|
||||
|
||||
def site_slug(site: str | None = None) -> str:
|
||||
return re.sub(r"[^A-Za-z0-9]+", "", site or config.BLANCCO_SITE) or "Site"
|
||||
|
||||
|
||||
def collect(since=None, until=None) -> list[dict]:
|
||||
"""Every XML report on the share dated in [since, until), oldest first.
|
||||
|
||||
Each entry: {"file", "pdf" (or None), "when", "data", "document_id"}.
|
||||
Dated by the timestamp inside the report, mtime only as a fallback.
|
||||
"""
|
||||
folder = config.BLANCCO_REPORTS
|
||||
names = set(os.listdir(folder)) if os.path.isdir(folder) else set()
|
||||
out = []
|
||||
for f in sorted(names):
|
||||
path = os.path.join(folder, f)
|
||||
if not f.lower().endswith(".xml") or f.startswith(".") or not os.path.isfile(path):
|
||||
continue
|
||||
try:
|
||||
data = blancco_report.parse(path)
|
||||
except Exception:
|
||||
data = {}
|
||||
when = blancco_report.report_datetime(data) or datetime.fromtimestamp(
|
||||
os.stat(path).st_mtime, timezone.utc)
|
||||
if since and when < since:
|
||||
continue
|
||||
if until and when >= until:
|
||||
continue
|
||||
pdf = os.path.splitext(f)[0] + ".pdf"
|
||||
out.append({
|
||||
"file": f,
|
||||
"pdf": pdf if pdf in names else None,
|
||||
"when": when,
|
||||
"data": data,
|
||||
"document_id": (data.get("meta") or {}).get("document_id", ""),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def month_bounds(month: str):
|
||||
"""'2026-10' -> (2026-10-01T00:00Z, 2026-11-01T00:00Z)."""
|
||||
y, m = (int(x) for x in month.split("-"))
|
||||
start = datetime(y, m, 1, tzinfo=timezone.utc)
|
||||
end = datetime(y + (m == 12), m % 12 + 1, 1, tzinfo=timezone.utc)
|
||||
return start, end
|
||||
|
||||
|
||||
def previous_month(now=None) -> str:
|
||||
now = now or datetime.now(timezone.utc)
|
||||
y, m = (now.year, now.month - 1) if now.month > 1 else (now.year - 1, 12)
|
||||
return f"{y:04d}-{m:02d}"
|
||||
|
||||
|
||||
def summary_csv(reports: list[dict], state: dict) -> str:
|
||||
buf = io.StringIO()
|
||||
writer = csv.DictWriter(buf, fieldnames=blancco_report.EXPORT_COLUMNS)
|
||||
writer.writeheader()
|
||||
for r in reports:
|
||||
writer.writerows(blancco_report.export_rows(
|
||||
r["file"], r["data"], r["when"].isoformat(),
|
||||
cloud_status(state, r["document_id"])))
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def evidence_name(month: str, site: str | None = None) -> str:
|
||||
return f"ENT-056_{site_slug(site)}_{month}"
|
||||
|
||||
|
||||
def build_evidence_zip(month: str, fileobj) -> int:
|
||||
"""Write the evidence pack for `month` into fileobj; return report count."""
|
||||
start, end = month_bounds(month)
|
||||
reports = collect(start, end)
|
||||
state = load_state()
|
||||
stem = evidence_name(month)
|
||||
with zipfile.ZipFile(fileobj, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr(f"{stem}/summary.csv", summary_csv(reports, state))
|
||||
zf.writestr(f"{stem}/README.txt", (
|
||||
f"Blancco erasure evidence, {config.BLANCCO_SITE}, {month} (UTC)\n"
|
||||
f"{len(reports)} report(s). summary.csv has one row per erased drive,\n"
|
||||
"with failure_reason for failed wipes and cloud_upload as recorded by\n"
|
||||
"the PXE server. Certificates of record are in the Blancco Management\n"
|
||||
"Portal; these are the site copies.\n"))
|
||||
for r in reports:
|
||||
zf.write(os.path.join(config.BLANCCO_REPORTS, r["file"]), f"{stem}/{r['file']}")
|
||||
if r["pdf"]:
|
||||
zf.write(os.path.join(config.BLANCCO_REPORTS, r["pdf"]), f"{stem}/{r['pdf']}")
|
||||
return len(reports)
|
||||
|
||||
|
||||
def servicenow_items(days: int = 30, include_done: bool = False) -> list[dict]:
|
||||
"""Reports to reflect on the Onsite Bin Management RITM, newest first."""
|
||||
since = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
state = load_state()
|
||||
items = []
|
||||
for r in reversed(collect(since)):
|
||||
done = state["servicenow"].get(r["document_id"])
|
||||
if done and not include_done:
|
||||
continue
|
||||
data = r["data"]
|
||||
system = (data.get("hardware") or {}).get("system") or {}
|
||||
if isinstance(system, list):
|
||||
system = system[0] if system else {}
|
||||
erasures = data.get("erasures") or []
|
||||
failed = [e for e in erasures
|
||||
if (e.get("state") or "").strip().lower() != "successful"]
|
||||
reasons = [blancco_report.failure_reason(e) for e in failed]
|
||||
drives = ", ".join(
|
||||
f"{(e.get('target') or {}).get('model', '')} {(e.get('target') or {}).get('serial', '')}".strip()
|
||||
for e in failed)
|
||||
items.append({
|
||||
"document_id": r["document_id"],
|
||||
"file": r["file"],
|
||||
"when": r["when"],
|
||||
"serial": system.get("serial", ""),
|
||||
"model": system.get("model", ""),
|
||||
"failed": bool(failed) or not erasures,
|
||||
"note": (f"Blancco wipe failed on {drives or 'drive'}: "
|
||||
f"{'; '.join(x.rstrip('. ') for x in reasons if x) or 'no erasure recorded'}. "
|
||||
"Drive to be destroyed; record Certificate of Destruction.")
|
||||
if (failed or not erasures) else "",
|
||||
"cloud": cloud_status(state, r["document_id"]),
|
||||
"done": done.get("ts") if done else None,
|
||||
})
|
||||
return items
|
||||
@@ -125,12 +125,54 @@ EXPORT_COLUMNS = [
|
||||
"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",
|
||||
"total_errors", "failure_reason", "license_consumption_id",
|
||||
"business_name", "business_location", "cloud_upload",
|
||||
]
|
||||
|
||||
|
||||
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."""
|
||||
def _as_list(x):
|
||||
if x is None or x == "":
|
||||
return []
|
||||
return x if isinstance(x, list) else [x]
|
||||
|
||||
|
||||
def failure_reason(er: dict) -> str:
|
||||
"""Why an erasure did not succeed, from the report itself; "" if it did.
|
||||
|
||||
Blancco records exception messages under erasure_details (e.g.
|
||||
"NBP area content was not erased."), a state and error count per step,
|
||||
and total/read/mismatch error counts. Auditors (ENT-158/159) want the
|
||||
cause of failure next to each failed wipe.
|
||||
"""
|
||||
if (er.get("state") or "").strip().lower() == "successful":
|
||||
return ""
|
||||
parts = []
|
||||
details = er.get("erasure_details") or {}
|
||||
for exc in _as_list(details.get("exception")) + _as_list(details.get("messages")):
|
||||
msg = exc.get("message") if isinstance(exc, dict) else exc
|
||||
if msg and msg not in parts:
|
||||
parts.append(msg)
|
||||
steps = (er.get("steps") or {}).get("step")
|
||||
for i, st in enumerate(_as_list(steps), 1):
|
||||
if isinstance(st, dict) and (st.get("state") or "completed") != "completed":
|
||||
name = st.get("name") or st.get("step_name") or f"step {i}"
|
||||
parts.append(f"{name}: {st.get('state')}")
|
||||
for key in ("total_errors", "read_errors", "mismatch_errors"):
|
||||
try:
|
||||
n = int(er.get(key) or 0)
|
||||
except ValueError:
|
||||
n = 0
|
||||
if n:
|
||||
parts.append(f"{key.replace('_', ' ')}: {n}")
|
||||
return "; ".join(parts) or f"state: {er.get('state') or 'unknown'}"
|
||||
|
||||
|
||||
def export_rows(filename: str, data: dict, when: str, cloud: str = "") -> list[dict]:
|
||||
"""One CSV row per erased drive; a report with no erasures still gets a row.
|
||||
|
||||
cloud is the upload status recorded by the webapp for this report
|
||||
("sent", "failed" or "" when unknown).
|
||||
"""
|
||||
meta = data.get("meta") or {}
|
||||
system = (data.get("hardware") or {}).get("system") or {}
|
||||
if isinstance(system, list):
|
||||
@@ -145,6 +187,8 @@ def export_rows(filename: str, data: dict, when: str) -> list[dict]:
|
||||
"system_serial": system.get("serial", ""),
|
||||
"license_consumption_id": ";".join(data.get("license_consumption_ids") or []),
|
||||
"business_name": (data.get("company") or {}).get("business_name", ""),
|
||||
"business_location": (data.get("company") or {}).get("business_location", ""),
|
||||
"cloud_upload": cloud,
|
||||
}
|
||||
rows = []
|
||||
for er in data.get("erasures") or []:
|
||||
@@ -167,5 +211,6 @@ def export_rows(filename: str, data: dict, when: str) -> list[dict]:
|
||||
"end_time": er.get("end_time", ""),
|
||||
"elapsed_time": er.get("elapsed_time", ""),
|
||||
"total_errors": er.get("total_errors", ""),
|
||||
"failure_reason": failure_reason(er),
|
||||
})
|
||||
return rows or [base]
|
||||
|
||||
@@ -9,6 +9,7 @@ from flask import abort, request, session
|
||||
# trust-by-network. Keep the list short and explicit.
|
||||
CSRF_EXEMPT_PATHS = {
|
||||
"/imaging/status",
|
||||
"/reports/cloud-status",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -29,6 +29,23 @@
|
||||
<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">CMMC Evidence - {{ site }}</div>
|
||||
<div class="d-flex flex-wrap align-items-center gap-3">
|
||||
<form method="get" action="{{ url_for('blancco_evidence_pack') }}" class="d-flex flex-wrap align-items-center gap-2">
|
||||
<label for="evidenceMonth" class="mb-0">Evidence pack (ENT-056/158) for</label>
|
||||
<input id="evidenceMonth" type="month" name="month" value="{{ last_month }}" class="form-control form-control-sm w-auto" required>
|
||||
<button type="submit" class="pxe-btn pxe-btn-secondary" title="summary.csv plus every report's XML and PDF for that month">
|
||||
<i class="bi bi-file-zip"></i> Download
|
||||
</button>
|
||||
</form>
|
||||
<a href="{{ url_for('blancco_servicenow') }}" class="pxe-btn pxe-btn-secondary" title="What to tick on each Onsite Bin Management RITM">
|
||||
<i class="bi bi-list-check"></i> ServiceNow tick list (ENT-045/159)
|
||||
</a>
|
||||
</div>
|
||||
<p class="small text-muted mt-2 mb-0">The evidence pack for last month is also saved automatically to <span class="mono">evidence\</span> on the reports share on the 1st. Certificates of record are in the Blancco Management Portal.</p>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="section-title">Drive Erasure Certificates</div>
|
||||
{% if reports %}
|
||||
@@ -40,6 +57,7 @@
|
||||
<th>Model</th>
|
||||
<th>Date</th>
|
||||
<th>Result</th>
|
||||
<th title="Whether Blancco reported uploading this report to the Blancco cloud">Cloud</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -55,6 +73,11 @@
|
||||
{% elif r.state %}<span class="badge badge-secondary">{{ r.state }}</span>
|
||||
{% else %}<span class="text-muted">-</span>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if r.cloud == 'sent' %}<span class="badge badge-success">Sent</span>
|
||||
{% elif r.cloud == 'failed' %}<span class="badge badge-danger" title="Resend from Blancco, or upload the XML to the Blancco portal">Not sent</span>
|
||||
{% else %}<span class="text-muted" title="No upload status recorded (older report, or saved manually)">-</span>{% endif %}
|
||||
</td>
|
||||
<td class="actions text-end text-nowrap">
|
||||
{% if r.filename.lower().endswith('.xml') %}
|
||||
<a href="{{ url_for('blancco_view_report', filename=r.filename) }}"
|
||||
|
||||
104
webapp/templates/reports_servicenow.html
Normal file
104
webapp/templates/reports_servicenow.html
Normal file
@@ -0,0 +1,104 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}ServiceNow Tick List - PXE Server Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1><i class="bi bi-list-check"></i> ServiceNow Tick List</h1>
|
||||
<div class="header-actions">
|
||||
<a href="{{ url_for('blancco_reports') }}" class="pxe-btn pxe-btn-ghost"><i class="bi bi-arrow-left"></i> Erasure reports</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="section-title">Onsite Bin Management RITM (ENT-045 / ENT-159)</div>
|
||||
<p class="mb-2">
|
||||
For each wiped asset, open its Onsite Bin Management RITM and tick
|
||||
<strong>Wiped</strong>, or <strong>Blancco Wipe failed</strong> with the destruction note below.
|
||||
Then click <strong>Done</strong> here so it drops off the list.
|
||||
</p>
|
||||
<form method="get" class="d-flex flex-wrap align-items-center gap-2 mb-0">
|
||||
<label for="days" class="mb-0">Last</label>
|
||||
<select id="days" name="days" class="form-select form-select-sm w-auto" onchange="this.form.submit()">
|
||||
{% for d in (7, 30, 90, 366) %}<option value="{{ d }}" {% if d == days %}selected{% endif %}>{{ d }} days</option>{% endfor %}
|
||||
</select>
|
||||
<label class="mb-0 ms-2"><input type="checkbox" name="all" value="1" {% if include_done %}checked{% endif %} onchange="this.form.submit()"> show done</label>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
{% if items %}
|
||||
<div class="table-container">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Serial</th>
|
||||
<th>Model</th>
|
||||
<th>Wiped (UTC)</th>
|
||||
<th>Tick in ServiceNow</th>
|
||||
<th>Destruction note</th>
|
||||
<th>Cloud</th>
|
||||
<th class="text-end">Done</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for it in items %}
|
||||
<tr title="{{ it.file }}">
|
||||
<td class="text-nowrap">
|
||||
<span class="mono">{{ it.serial or '-' }}</span>
|
||||
{% if it.serial %}<button type="button" class="btn btn-sm btn-outline-secondary btn-row-action copy-btn" data-copy-text="{{ it.serial }}">copy</button>{% endif %}
|
||||
</td>
|
||||
<td>{{ it.model or '-' }}</td>
|
||||
<td class="mono text-nowrap">{{ it.when.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td>
|
||||
{% if it.failed %}<span class="badge badge-danger">Blancco Wipe failed</span>
|
||||
{% else %}<span class="badge badge-success">Wiped</span>{% endif %}
|
||||
</td>
|
||||
<td style="max-width: 28rem;">
|
||||
{% if it.note %}
|
||||
<div class="small">{{ it.note }}</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary btn-row-action copy-btn mt-1" data-copy-text="{{ it.note }}">copy note</button>
|
||||
{% else %}<span class="text-muted">-</span>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if it.cloud == 'sent' %}<span class="badge badge-success">Sent</span>
|
||||
{% elif it.cloud == 'failed' %}<span class="badge badge-danger">Not sent</span>
|
||||
{% else %}<span class="text-muted">-</span>{% endif %}
|
||||
</td>
|
||||
<td class="text-end text-nowrap">
|
||||
<form method="post" action="{{ url_for('blancco_servicenow_mark', document_id=it.document_id, all='1' if include_done else None) }}" style="display:inline;">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
{% if it.done %}
|
||||
<input type="hidden" name="done" value="0">
|
||||
<button type="submit" class="pxe-btn pxe-btn-ghost btn-row-action" title="Ticked {{ it.done }}">Undo</button>
|
||||
{% else %}
|
||||
<input type="hidden" name="done" value="1">
|
||||
<button type="submit" class="pxe-btn pxe-btn-success btn-row-action">Done</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-5">
|
||||
<p class="mb-1">Nothing left to tick for the last {{ days }} days.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
document.querySelectorAll('.copy-btn').forEach(function (b) {
|
||||
b.addEventListener('click', function () {
|
||||
var t = b.getAttribute('data-copy-text') || '';
|
||||
var done = function () { var o = b.textContent; b.textContent = 'copied'; setTimeout(function () { b.textContent = o; }, 1200); };
|
||||
if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(t).then(done); return; }
|
||||
var ta = document.createElement('textarea'); ta.value = t; document.body.appendChild(ta); ta.select();
|
||||
try { document.execCommand('copy'); done(); } finally { document.body.removeChild(ta); }
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user