diff --git a/webapp/app.py b/webapp/app.py index e602f69..0d9f79f 100644 --- a/webapp/app.py +++ b/webapp/app.py @@ -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 diff --git a/webapp/services/blancco_evidence.py b/webapp/services/blancco_evidence.py index 3f58c40..eed13d2 100644 --- a/webapp/services/blancco_evidence.py +++ b/webapp/services/blancco_evidence.py @@ -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" diff --git a/webapp/templates/reports.html b/webapp/templates/reports.html index c17b11d..50a3387 100644 --- a/webapp/templates/reports.html +++ b/webapp/templates/reports.html @@ -63,9 +63,19 @@
Evidence pack - @@ -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 = ''; diff --git a/webapp/templates/reports_todo.html b/webapp/templates/reports_todo.html index d506fc5..a8bfc6c 100644 --- a/webapp/templates/reports_todo.html +++ b/webapp/templates/reports_todo.html @@ -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 ---- #}

Evidence packs to upload

Built on the 1st of each month. Upload to the site's ENT-056 / ENT-158 folders in Box, then mark.
{% for p in todo.evidence %} -
{{ p.month }}{{ p.file }}
+
{{ p.month | month_label }}{{ p.file }}
Download {{ attest('evidence_uploaded', p.month, 'Mark uploaded', 'Record that ' ~ p.file ~ ' is uploaded to Box? This is logged in the audit log.') }}
{% else %}
No pack waiting. The next one is built on the 1st.
{% endfor %} {% for r in recent.evidence_uploaded %} -
{{ r.key }}Evidence packUploaded {{ r.ts | local_dt }}
+
{{ r.key | month_label }}Evidence packUploaded {{ r.ts | local_dt }}
{{ undo_form('evidence_uploaded', r.key, 'evidence pack ' ~ r.key ~ ' uploaded') }}
{% endfor %} @@ -186,9 +187,12 @@
- {{ r.model }} - {{ what | join('; ') }} - seen {{ r.last_seen | local_dt }} + {{ r.last_seen | short_date }} + {{ r.model }} + + {% 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 %} +