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:
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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">▾</span>
|
||||
</summary>
|
||||
<div class="tbody"><dl class="kv">
|
||||
|
||||
Reference in New Issue
Block a user