Webapp: evidence pack date range, US month labels, hardware gap rows

Evidence pack takes ?from=&to= (inclusive, Eastern time) with From/To
pickers and quick ranges; ZIP is named ENT-056_<site>_<from>_to_<to>.
Month keys show as 'September 2026'. Hardware gap rows on the to-do
use the shared one-line grid with chips for the missing pack or BIOS.
This commit is contained in:
cproudlock
2026-10-14 15:08:36 -04:00
parent a54df7ebd9
commit 2dbaaded44
4 changed files with 116 additions and 15 deletions

View File

@@ -694,6 +694,8 @@ def blancco_reports():
this_month=datetime.now(tz).strftime("%Y-%m"),
site=config.BLANCCO_SITE,
last_month=blancco_evidence.previous_month(),
pack_from=blancco_evidence.month_bounds(blancco_evidence.previous_month())[0].date().isoformat(),
pack_to=(blancco_evidence.month_bounds(blancco_evidence.previous_month())[1] - timedelta(days=1)).date().isoformat(),
todo=blancco_evidence.todo_counts(),
)
@@ -789,7 +791,27 @@ def blancco_cloud_status():
@app.route("/reports/evidence")
def blancco_evidence_pack():
"""CMMC evidence pack (ENT-056/158): one ZIP per site and month."""
"""CMMC evidence pack (ENT-056/158) for a site. Either ?from=&to= (dates,
YYYY-MM-DD, inclusive, Eastern time) or ?month=YYYY-MM (the monthly pack)."""
from datetime import date as _date
first, last = request.args.get("from"), request.args.get("to")
if first or last:
try:
first, last = _date.fromisoformat(first or ""), _date.fromisoformat(last or "")
except ValueError:
flash("Pick a start and an end date for the evidence pack.", "danger")
return redirect(url_for("blancco_reports"))
if last < first:
first, last = last, first
if (last - first).days > 400:
flash("An evidence pack covers at most about a year (400 days).", "danger")
return redirect(url_for("blancco_reports"))
out = tempfile.SpooledTemporaryFile(max_size=64 * 1024 * 1024)
count = blancco_evidence.build_evidence_zip(None, out, first=first, last=last)
out.seek(0)
audit("REPORT_EVIDENCE", f"{first.isoformat()}..{last.isoformat()} {count} reports")
return send_file(out, mimetype="application/zip", as_attachment=True,
download_name=blancco_evidence.evidence_range_name(first, last) + ".zip")
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)
@@ -1822,6 +1844,27 @@ def tojson_ordered(value):
return json.dumps(value, ensure_ascii=False)
@app.template_filter("short_date")
def short_date(value):
"""ISO timestamp -> '14 Oct' in the site's time zone; '' if unreadable."""
try:
dt = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
except ValueError:
return ""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(blancco_evidence._tz()).strftime("%d %b")
@app.template_filter("month_label")
def month_label(value):
"""'2026-09' -> 'September 2026'; anything else unchanged."""
try:
return datetime.strptime(str(value), "%Y-%m").strftime("%B %Y")
except ValueError:
return value
# Report page: why an erasure failed, straight from the report.
app.jinja_env.globals["blancco_failure_reason"] = blancco_report.failure_reason

View File

@@ -182,16 +182,47 @@ 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)
def evidence_range_name(first, last, site: str | None = None) -> str:
"""File names stay year-first so they sort; the pages show US dates."""
return f"ENT-056_{site_slug(site)}_{first.isoformat()}_to_{last.isoformat()}"
def day_bounds(first, last):
"""date, date -> start of `first` and of the day after `last`, in the
site's display timezone (America/New_York): a whole-day range."""
from datetime import timedelta
start = datetime(first.year, first.month, first.day, tzinfo=_tz())
nxt = last + timedelta(days=1)
return start, datetime(nxt.year, nxt.month, nxt.day, tzinfo=_tz())
def us_date(d) -> str:
"""10/14/2026: the date format the site reads."""
return f"{d.month:02d}/{d.day:02d}/{d.year:04d}"
def range_label(first, last) -> str:
return f"{us_date(first)} to {us_date(last)}"
def build_evidence_zip(month, fileobj, first=None, last=None) -> int:
"""Write an evidence pack into fileobj; return the report count. Either a
calendar month ('2026-09', the monthly pack) or a day range first..last
(dates, inclusive, Eastern time)."""
if first and last:
start, end = day_bounds(first, last)
stem = evidence_range_name(first, last)
period = range_label(first, last)
else:
start, end = month_bounds(month)
stem = evidence_name(month)
period = 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} ({config.DISPLAY_TZ})\n"
f"Blancco erasure evidence, {config.BLANCCO_SITE}, {period} ({config.DISPLAY_TZ})\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"

View File

@@ -63,9 +63,19 @@
</details>
<details class="more">
<summary class="ui-btn">Evidence pack</summary>
<form class="menu" method="get" action="{{ url_for('blancco_evidence_pack') }}" style="width: min(320px, calc(100vw - 32px)); padding: 14px; gap: 10px">
<label for="evidenceMonth" class="sub">ENT-056/158 evidence pack for</label>
<input id="evidenceMonth" type="month" name="month" value="{{ last_month }}" class="field" required>
<form class="menu" method="get" action="{{ url_for('blancco_evidence_pack') }}" style="width: min(380px, calc(100vw - 32px)); padding: 14px; gap: 10px">
<div class="sub" style="white-space: normal">ENT-056/158 evidence pack: every report erased between these dates (Eastern time, both days included).</div>
<div style="display: flex; gap: 8px; flex-wrap: wrap">
<label class="sub" style="display: flex; flex-direction: column; gap: 4px">From
<input id="evidenceFrom" type="date" name="from" value="{{ pack_from }}" class="field" required></label>
<label class="sub" style="display: flex; flex-direction: column; gap: 4px">To
<input id="evidenceTo" type="date" name="to" value="{{ pack_to }}" class="field" required></label>
</div>
<div style="display: flex; gap: 6px; flex-wrap: wrap" aria-label="Quick ranges">
<button type="button" class="fbtn" data-range="last-month">Last month</button>
<button type="button" class="fbtn" data-range="this-month">This month</button>
<button type="button" class="fbtn" data-range="last-30">Last 30 days</button>
</div>
<button type="submit" class="ui-btn pri">Download</button>
<div class="sub" style="white-space: normal">Built automatically into <span class="mono">evidence\</span> on the reports share on the 1st.</div>
</form>
@@ -191,6 +201,19 @@
document.getElementById('deleteFilename').textContent = filename;
document.getElementById('deleteForm').action = "{{ url_for('blancco_delete_report', filename='__F__') }}".replace('__F__', encodeURIComponent(filename));
});
// Evidence pack quick ranges (dates in the browser's local day; the
// server reads them as Eastern-time days).
function iso(d) { return d.getFullYear() + '-' + ('0' + (d.getMonth() + 1)).slice(-2) + '-' + ('0' + d.getDate()).slice(-2); }
document.querySelectorAll('[data-range]').forEach(function (b) {
b.addEventListener('click', function () {
var now = new Date(), from, to;
if (b.dataset.range === 'last-month') { from = new Date(now.getFullYear(), now.getMonth() - 1, 1); to = new Date(now.getFullYear(), now.getMonth(), 0); }
else if (b.dataset.range === 'this-month') { from = new Date(now.getFullYear(), now.getMonth(), 1); to = now; }
else { to = now; from = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 29); }
document.getElementById('evidenceFrom').value = iso(from);
document.getElementById('evidenceTo').value = iso(to);
});
});
// Search and filters: rows hide; a month with no visible row hides; a
// search or filter opens the months so matches are seen.
var q = document.getElementById('rq'), f = '';

View File

@@ -22,7 +22,8 @@
.tgrid { display: grid; grid-template-columns: 96px 104px minmax(0, 1fr) 200px 14px; align-items: center; gap: 0 14px;
padding: 11px 210px 11px 24px; font-size: 14px; min-height: 24px; }
.tgrid.nochev { grid-template-columns: 96px 104px minmax(0, 1fr) 200px; }
.tgrid.hw { grid-template-columns: minmax(0, 1fr) minmax(0, 260px) 110px 14px; }
.tgrid.hw { grid-template-columns: 96px minmax(0, 1fr) auto 14px; }
.hw-miss { display: flex; gap: 6px; flex-wrap: nowrap; justify-content: flex-end; min-width: 0; }
.twhen { font-size: 13px; color: var(--muted); white-space: nowrap; }
.ui-row.done { background: var(--surface-2); }
.ui-row.done .tgrid { color: var(--muted); }
@@ -152,14 +153,14 @@
{# ---- 3. Evidence packs ---- #}
<div class="sect" id="ev"><h2>Evidence packs to upload</h2><span class="sub">Built on the 1st of each month. Upload to the site's ENT-056 / ENT-158 folders in Box, then mark.</span></div>
{% for p in todo.evidence %}
<div class="ui-row"><div class="tgrid nochev"><span class="twhen mono">{{ p.month }}</span><span class="mono trunc" style="grid-column: span 2">{{ p.file }}</span><span></span></div>
<div class="ui-row"><div class="tgrid nochev"><span class="twhen">{{ p.month | month_label }}</span><span class="mono trunc" style="grid-column: span 2">{{ p.file }}</span><span></span></div>
<div class="act"><a class="ui-btn" href="{{ url_for('blancco_evidence_file', filename=p.file) }}">Download</a>
{{ attest('evidence_uploaded', p.month, 'Mark uploaded', 'Record that ' ~ p.file ~ ' is uploaded to Box? This is logged in the audit log.') }}</div></div>
{% else %}
<div class="empty">No pack waiting. The next one is built on the 1st.</div>
{% endfor %}
{% for r in recent.evidence_uploaded %}
<div class="ui-row done"><div class="tgrid nochev"><span class="twhen mono">{{ r.key }}</span><span style="grid-column: span 2">Evidence pack</span><span class="sub">Uploaded {{ r.ts | local_dt }}</span></div>
<div class="ui-row done"><div class="tgrid nochev"><span class="twhen">{{ r.key | month_label }}</span><span style="grid-column: span 2">Evidence pack</span><span class="sub">Uploaded {{ r.ts | local_dt }}</span></div>
<div class="act">{{ undo_form('evidence_uploaded', r.key, 'evidence pack ' ~ r.key ~ ' uploaded') }}</div></div>
{% endfor %}
@@ -186,9 +187,12 @@
<div class="ui-row">
<details>
<summary class="tgrid hw">
<span class="trunc mono c-model">{{ r.model }}</span>
<span class="sub trunc">{{ what | join('; ') }}</span>
<span class="twhen">seen {{ r.last_seen | local_dt }}</span>
<span class="twhen mono" title="Last seen {{ r.last_seen | local_dt }}">{{ r.last_seen | short_date }}</span>
<span class="trunc c-model">{{ r.model }}</span>
<span class="hw-miss">
{% if r.missing %}{{ ui.chip('No driver pack' if r.missing | length > 1 else 'No pack: ' ~ (r.missing[0] | replace('gea-', '')), 'err') }}{% endif %}
{% if r.bios.status == 'none' %}{{ ui.chip('No BIOS entry', 'warn') }}{% elif r.bios.status == 'file-missing' %}{{ ui.chip('BIOS file missing', 'err') }}{% endif %}
</span>
<span class="chev" aria-hidden="true">&#9662;</span>
</summary>
<div class="tbody"><dl class="kv">