added reporting feature
Build and Push Docker Image / build (push) Successful in 22s

This commit is contained in:
Jason Stedwell
2026-06-19 09:10:34 -05:00
parent 98793fbecf
commit cdca5557d1
3 changed files with 507 additions and 3 deletions
+40
View File
@@ -17,6 +17,11 @@ Merge those identities into a single "person" so the dashboard computes ONE
first-badge time per human, not per badge UUID — no more false LATE warnings
for staff who badge into multiple buildings before the cutoff. (05/28/26)
**Reporting:** export first badge-in times over a date range for one person or a
multi-selected group. View an on-screen pivot (users × dates) or download a
long-format CSV. Days with no badge-in are reported as **absent**, so it doubles
as an attendance report. (06/19/26)
---
## Requirements
@@ -182,6 +187,7 @@ Per-controller actions in the modal:
| **Refresh** | Reload the table |
| **Sync Users** | Pull latest users from every enabled controller |
| **🚫 Filtered** | Open the filtered-tenants modal to review and unhide |
| **📊 Report** | Open the date-range report builder (first badge-in per day, per user, CSV export) |
| **👥 People** | Manage merged identities and review auto-suggested merges |
| **⚙ Controllers** | Add / manage controllers |
| **Reset Day** | Delete all badge records for the selected date (respects the Controller filter — testing only) |
@@ -255,6 +261,38 @@ filtered — unhiding restores their full history with no gaps.
---
## Reporting over a date range
The live table only shows one day. To pull first badge-in times across a range —
for a single person or a whole group — click **📊 Report** in the header.
In the report modal:
- **Start / End** — the inclusive date range (capped at 366 days).
- **On-time cutoff** — the HH:MM threshold for ON TIME vs LATE (defaults to the
cutoff set in the main controls).
- **Users** — a searchable checklist of every report subject: each merged
**person** plus every unmerged actor. Pick one for an individual report, or
several for a group. **Select all** and **Clear** act on the current search.
- **Run Report** renders an on-screen **pivot**: one row per selected user, one
column per date, each cell showing that day's first badge-in time
(green = on time, red = late). Weekends are shaded and days with no badge-in
show as **—**.
- **Export CSV** downloads the same data in **long format** — one row per
user-per-day (`Name, Date, Weekday, First In, Status, Sources`) — ideal for
Excel or Sheets.
The report respects the header's **Controller** filter and **Show filtered**
toggle, and it uses the same identity-merging logic as the live table, so a
merged person reports **one** first-badge time per day computed across all their
controllers. Days a selected user never badged in are reported as **ABSENT**,
making the export a complete attendance record for the range.
> Reporting reads existing `badge_events` only — no schema changes, no extra
> storage. First-badge-of-day is just `MIN(ts)` per `(person, date)`.
---
## Updating from GitHub
```bash
@@ -281,6 +319,8 @@ reverse proxy with auth in front of it.
| `POST` | `/api/unifi-access/<controller_id>` | webhook body | Receives UniFi Access webhook for that controller |
| `POST` | `/api/unifi-access` | webhook body | Legacy alias — routes to the oldest controller |
| `GET` | `/api/first-badge-status` | `date`, `cutoff`, `controller_id?`, `include_filtered?` | Returns first + latest badge per user (filtered tenants hidden unless `include_filtered=1`) |
| `GET` | `/api/report/subjects` | `controller_id?`, `include_filtered?` | List selectable report subjects (merged persons + unmerged actors), each with a stable `key` |
| `GET` | `/api/report` | `start`, `end`, `cutoff?`, `subjects?`, `controller_id?`, `include_filtered?`, `format?` | First badge-in per subject per day over the range. JSON (pivot-shaped) by default; `format=csv` returns a long-format download. Missing days reported as `ABSENT`. `subjects` is a comma-separated list of subject `key`s (empty = all) |
| `GET` | `/api/users` | `controller_id?`, `filtered?` | List cached actors with their filtered flag |
| `PATCH` | `/api/users/<controller_id>/<actor_id>` | `filtered` (bool) | Hide / unhide an actor from the attendance table |
| `GET` | `/api/persons` | — | List merged people with their members |
+216 -3
View File
@@ -1,8 +1,8 @@
import os, hmac, hashlib, json, logging, uuid, re
from datetime import datetime, timezone
import os, hmac, hashlib, json, logging, uuid, re, csv, io
from datetime import datetime, timezone, timedelta
from urllib.parse import urljoin
from flask import Flask, request, jsonify
from flask import Flask, request, jsonify, Response
import pytz, sqlite3
from apscheduler.schedulers.background import BackgroundScheduler
import requests, urllib3
@@ -399,6 +399,219 @@ def first_badge_status():
return jsonify(result)
# ---------------------------------------------------------------------------
# Reporting — first badge-in times over a date range
# ---------------------------------------------------------------------------
_WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
def _weekday(date_str):
return _WEEKDAYS[datetime.strptime(date_str, "%Y-%m-%d").weekday()]
def _date_series(start, end):
"""Inclusive list of YYYY-MM-DD strings from start to end."""
d0 = datetime.strptime(start, "%Y-%m-%d").date()
d1 = datetime.strptime(end, "%Y-%m-%d").date()
days, cur = [], d0
while cur <= d1:
days.append(cur.isoformat())
cur += timedelta(days=1)
return days
def _build_subject_universe(db, controller_filter, include_filtered):
"""The selectable report subjects, keyed by group_key — merged persons plus
every unmerged actor. Mirrors the grouping used by /api/first-badge-status."""
subjects = {}
for p in db.execute("SELECT * FROM persons").fetchall():
if not include_filtered and p["filtered"]:
continue
if controller_filter:
on_ctrl = db.execute(
"SELECT 1 FROM person_members WHERE person_id = ? AND controller_id = ?",
(p["id"], controller_filter),
).fetchone()
if not on_ctrl:
continue
subjects[p["id"]] = {
"key": p["id"],
"name": p["display_name"],
"controller_name": "Merged",
"filtered": bool(p["filtered"]),
"merged": True,
}
sql = """
SELECT u.controller_id, u.actor_id, u.full_name,
COALESCE(u.filtered, 0) AS filtered,
c.name AS controller_name
FROM user_cache u
LEFT JOIN controllers c ON c.id = u.controller_id
LEFT JOIN person_members pm
ON pm.controller_id = u.controller_id AND pm.actor_id = u.actor_id
WHERE pm.person_id IS NULL
"""
params = []
if controller_filter:
sql += " AND u.controller_id = ?"
params.append(controller_filter)
if not include_filtered:
sql += " AND COALESCE(u.filtered, 0) = 0"
for u in db.execute(sql, params).fetchall():
key = f"{u['controller_id']}|{u['actor_id']}"
subjects[key] = {
"key": key,
"name": u["full_name"] or f"User {u['actor_id'][:8]}",
"controller_name": u["controller_name"] or "",
"filtered": bool(u["filtered"]),
"merged": False,
}
return subjects
def _run_range_query(db, start, end, controller_filter, include_filtered):
"""Map of (group_key, date) -> first badge-in for the whole range."""
sql = """
SELECT
COALESCE(pm.person_id, b.controller_id || '|' || b.actor_id) AS group_key,
b.date AS date,
MIN(b.ts) AS first_ts,
GROUP_CONCAT(DISTINCT c.name) AS sources
FROM badge_events b
LEFT JOIN person_members pm
ON pm.controller_id = b.controller_id AND pm.actor_id = b.actor_id
LEFT JOIN persons p ON p.id = pm.person_id
LEFT JOIN user_cache u
ON u.actor_id = b.actor_id AND u.controller_id = b.controller_id
LEFT JOIN controllers c ON c.id = b.controller_id
WHERE b.date BETWEEN ? AND ?
"""
params = [start, end]
if controller_filter:
sql += " AND b.controller_id = ?"
params.append(controller_filter)
if not include_filtered:
sql += " AND COALESCE(p.filtered, u.filtered, 0) = 0"
sql += " GROUP BY group_key, date"
out = {}
for r in db.execute(sql, params).fetchall():
out[(r["group_key"], r["date"])] = {
"first_ts": r["first_ts"],
"sources": [s for s in (r["sources"] or "").split(",") if s],
}
return out
@app.route("/api/report/subjects")
def report_subjects():
controller_filter = request.args.get("controller_id", "").strip() or None
include_filtered = request.args.get("include_filtered") == "1"
with get_db() as db:
universe = _build_subject_universe(db, controller_filter, include_filtered)
out = sorted(universe.values(), key=lambda s: s["name"].lower())
return jsonify([
{
"key": s["key"],
"name": s["name"],
"controller_name": s["controller_name"],
"merged": s["merged"],
"filtered": s["filtered"],
}
for s in out
])
@app.route("/api/report")
def report():
today = datetime.now(pytz.timezone(TZ)).strftime("%Y-%m-%d")
start = request.args.get("start", today)
end = request.args.get("end", today)
cutoff = request.args.get("cutoff", "09:00")
controller_filter = request.args.get("controller_id", "").strip() or None
include_filtered = request.args.get("include_filtered") == "1"
fmt = request.args.get("format", "json").lower()
subjects_arg = request.args.get("subjects", "").strip()
if not re.match(r"^\d{4}-\d{2}-\d{2}$", start) or not re.match(r"^\d{4}-\d{2}-\d{2}$", end):
return jsonify({"error": "start and end must be YYYY-MM-DD"}), 400
if start > end:
start, end = end, start
if not re.match(r"^\d{2}:\d{2}$", cutoff):
cutoff = "09:00"
cutoff_end = cutoff + ":59"
try:
days = _date_series(start, end)
except ValueError:
return jsonify({"error": "invalid date"}), 400
if len(days) > 366:
return jsonify({"error": "date range too large (max 366 days)"}), 400
selected_keys = [s for s in subjects_arg.split(",") if s] if subjects_arg else None
with get_db() as db:
universe = _build_subject_universe(db, controller_filter, include_filtered)
events = _run_range_query(db, start, end, controller_filter, include_filtered)
if selected_keys:
subjects = [universe[k] for k in selected_keys if k in universe]
else:
subjects = sorted(universe.values(), key=lambda s: s["name"].lower())
rows = []
for subj in subjects:
for d in days:
ev = events.get((subj["key"], d))
if ev:
first = ev["first_ts"]
status = "ON TIME" if first <= cutoff_end else "LATE"
sources = ev["sources"]
else:
first, status, sources = None, "ABSENT", []
rows.append({
"subject_key": subj["key"],
"name": subj["name"],
"date": d,
"weekday": _weekday(d),
"first_ts": first,
"status": status,
"sources": sources,
})
if fmt == "csv":
buf = io.StringIO()
w = csv.writer(buf)
w.writerow(["Name", "Date", "Weekday", "First In", "Status", "Sources"])
for r in rows:
w.writerow([
r["name"], r["date"], r["weekday"],
r["first_ts"] or "", r["status"], "; ".join(r["sources"]),
])
return Response(
buf.getvalue(),
mimetype="text/csv",
headers={
"Content-Disposition":
f'attachment; filename="badge-report_{start}_{end}.csv"'
},
)
return jsonify({
"start": start,
"end": end,
"cutoff": cutoff,
"dates": days,
"subjects": [
{"key": s["key"], "name": s["name"], "merged": s["merged"]}
for s in subjects
],
"rows": rows,
})
# ---------------------------------------------------------------------------
# Webhook ingestion — per-controller endpoint, plus legacy compat alias
# ---------------------------------------------------------------------------
+251
View File
@@ -125,6 +125,45 @@
border-color: rgba(100,180,255,0.6);
background: radial-gradient(circle at top, rgba(100,180,255,0.18), rgba(2,2,4,0.95));
}
.report-btn {
border-color: rgba(46,204,113,0.6);
background: radial-gradient(circle at top, rgba(46,204,113,0.18), rgba(2,2,4,0.95));
}
/* Report modal */
.modal.wide { max-width: 1000px; }
.report-controls {
display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end; margin-bottom: 14px;
}
.report-controls .control-group { display: flex; flex-direction: column; gap: 6px; }
.report-controls input { min-width: 130px; }
.subject-picker { display: flex; flex-direction: column; gap: 6px;
max-height: 220px; overflow-y: auto; margin: 6px 0 4px;
border: 1px solid var(--border); border-radius: 10px; padding: 8px; }
.subject-row {
display: flex; align-items: center; gap: 10px; padding: 5px 8px;
border-radius: 8px; cursor: pointer; font-size: 0.85rem;
}
.subject-row:hover { background: rgba(255,255,255,0.03); }
.subject-row input { width: auto; min-width: 0; margin: 0; }
.subject-row .s-sub { color: var(--muted); font-size: 0.72rem; margin-left: auto; }
.picker-toolbar { display: flex; gap: 12px; align-items: center; margin-bottom: 6px;
font-size: 0.74rem; }
.picker-toolbar a { color: var(--blue); cursor: pointer; text-decoration: underline; }
.picker-toolbar .count { color: var(--muted); margin-left: auto; }
.report-result { margin-top: 8px; overflow-x: auto; }
table.pivot { font-size: 0.82rem; }
table.pivot th, table.pivot td { padding: 8px 10px; white-space: nowrap; text-align: center; }
table.pivot th.name-col, table.pivot td.name-col {
text-align: left; position: sticky; left: 0; background: var(--bg-card); z-index: 1;
font-weight: 500; color: var(--text);
}
table.pivot td.cell-late { color: #ffd6d7; }
table.pivot td.cell-on { color: #c9f7dc; }
table.pivot td.cell-absent { color: var(--muted); }
table.pivot th.weekend, table.pivot td.weekend { background: rgba(255,255,255,0.02); }
.report-summary { font-size: 0.78rem; color: var(--muted); margin: 10px 0; }
.source-list { display: inline-flex; flex-wrap: wrap; gap: 4px; }
.merged-pill {
@@ -248,6 +287,7 @@
<button id="refresh-btn">&#8635; Refresh</button>
<button class="sync-btn" id="sync-btn">&#8635; Sync Users</button>
<button class="filtered-btn" id="open-filtered-btn">&#128683; Filtered</button>
<button class="report-btn" id="open-report-btn">&#128202; Report</button>
<button class="people-btn" id="open-people-btn">&#128101; People</button>
<button class="controllers-btn" id="open-controllers-btn">&#9881; Controllers</button>
<button class="reset-btn" id="reset-btn">&#x2715; Reset Day</button>
@@ -404,6 +444,52 @@
</div>
</div>
<!-- Report modal -->
<div class="modal-overlay" id="report-modal">
<div class="modal wide">
<h2>First Badge-In Report</h2>
<p>First badge-in per day over a date range. Pick one person for an individual
report, or several for a group. Days with no badge-in show as <strong>absent</strong>.</p>
<div class="report-controls">
<div class="control-group">
<label for="report-start">Start</label>
<input type="date" id="report-start">
</div>
<div class="control-group">
<label for="report-end">End</label>
<input type="date" id="report-end">
</div>
<div class="control-group">
<label for="report-cutoff">On-time cutoff</label>
<input type="time" id="report-cutoff" value="09:00">
</div>
</div>
<div class="section-label">Users</div>
<input type="text" id="report-search" class="picker-search" placeholder="Search by name…">
<div class="picker-toolbar">
<a id="report-select-all">Select all</a>
<a id="report-select-none">Clear</a>
<span class="count" id="report-sel-count">0 selected</span>
</div>
<div class="subject-picker" id="report-subjects">
<div class="empty-state">Loading…</div>
</div>
<div class="form-error" id="report-error"></div>
<div class="modal-actions">
<button class="modal-cancel" id="report-close">Close</button>
<button class="report-btn" id="report-csv-btn">&#11015; Export CSV</button>
<button id="report-run-btn">Run Report</button>
</div>
<div class="report-summary" id="report-summary"></div>
<div class="report-result" id="report-result"></div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
@@ -1206,6 +1292,171 @@
}
});
// -------- Report --------
let reportSubjects = [];
const reportSelected = new Set();
function reportParams(extra = {}) {
const p = new URLSearchParams({
start: document.getElementById('report-start').value || isoToday(),
end: document.getElementById('report-end').value || isoToday(),
cutoff: document.getElementById('report-cutoff').value || '09:00',
});
const cid = document.getElementById('controller-filter').value;
if (cid) p.set('controller_id', cid);
if (document.getElementById('show-filtered').checked) p.set('include_filtered', '1');
if (reportSelected.size) p.set('subjects', [...reportSelected].join(','));
Object.entries(extra).forEach(([k, v]) => p.set(k, v));
return p;
}
function updateReportSelCount() {
document.getElementById('report-sel-count').textContent =
`${reportSelected.size} selected`;
}
function renderReportSubjects() {
const term = document.getElementById('report-search').value.trim().toLowerCase();
const box = document.getElementById('report-subjects');
const list = reportSubjects.filter(s => s.name.toLowerCase().includes(term));
if (!list.length) {
box.innerHTML = '<div class="empty-state">No matching users.</div>';
return;
}
box.innerHTML = list.map(s => `
<label class="subject-row">
<input type="checkbox" data-key="${escapeHtml(s.key)}" ${reportSelected.has(s.key) ? 'checked' : ''}>
<span>${escapeHtml(s.name)}${s.merged ? ' <span class="merged-pill">MERGED</span>' : ''}</span>
<span class="s-sub">${escapeHtml(s.controller_name || '')}</span>
</label>
`).join('');
updateReportSelCount();
}
async function loadReportSubjects() {
const box = document.getElementById('report-subjects');
box.innerHTML = '<div class="empty-state">Loading…</div>';
const p = new URLSearchParams();
const cid = document.getElementById('controller-filter').value;
if (cid) p.set('controller_id', cid);
if (document.getElementById('show-filtered').checked) p.set('include_filtered', '1');
try {
const res = await fetch('/api/report/subjects?' + p.toString());
reportSubjects = await res.json();
} catch {
reportSubjects = [];
showToast('Could not load users', true);
}
renderReportSubjects();
}
async function runReport() {
const errEl = document.getElementById('report-error');
errEl.textContent = '';
const res = await fetch('/api/report?' + reportParams().toString());
let data;
try { data = await res.json(); } catch { data = null; }
if (!res.ok || !data) {
errEl.textContent = (data && data.error) || 'Report failed';
return;
}
renderPivot(data);
}
function renderPivot(data) {
const summary = document.getElementById('report-summary');
const result = document.getElementById('report-result');
if (!data.subjects.length) {
summary.textContent = '';
result.innerHTML = '<div class="empty-state">No users selected (or none match).</div>';
return;
}
// index rows by subject_key + date
const byKey = {};
data.rows.forEach(r => {
(byKey[r.subject_key] = byKey[r.subject_key] || {})[r.date] = r;
});
let late = 0, absent = 0, present = 0;
data.rows.forEach(r => {
if (r.status === 'LATE') late++;
else if (r.status === 'ABSENT') absent++;
else present++;
});
summary.textContent =
`${data.subjects.length} user(s) · ${data.dates.length} day(s) · ` +
`${present} on time · ${late} late · ${absent} absent`;
const isWeekend = d => { const g = new Date(d + 'T00:00:00').getDay(); return g === 0 || g === 6; };
const head = '<th class="name-col">Name</th>' + data.dates.map(d => {
const label = d.slice(5); // MM-DD
return `<th class="${isWeekend(d) ? 'weekend' : ''}">${label}</th>`;
}).join('');
const body = data.subjects.map(s => {
const cells = data.dates.map(d => {
const r = byKey[s.key] && byKey[s.key][d];
const wknd = isWeekend(d) ? ' weekend' : '';
if (!r || r.status === 'ABSENT')
return `<td class="cell-absent${wknd}">—</td>`;
const cls = r.status === 'LATE' ? 'cell-late' : 'cell-on';
return `<td class="${cls}${wknd}">${escapeHtml(r.first_ts)}</td>`;
}).join('');
return `<tr><td class="name-col">${escapeHtml(s.name)}</td>${cells}</tr>`;
}).join('');
result.innerHTML =
`<table class="pivot"><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
}
function openReportModal() {
const today = isoToday();
const start = new Date(); start.setDate(start.getDate() - 6);
document.getElementById('report-start').value = start.toISOString().slice(0, 10);
document.getElementById('report-end').value = today;
document.getElementById('report-cutoff').value =
document.getElementById('cutoff').value || '09:00';
document.getElementById('report-search').value = '';
document.getElementById('report-error').textContent = '';
document.getElementById('report-summary').textContent = '';
document.getElementById('report-result').innerHTML = '';
reportSelected.clear();
updateReportSelCount();
document.getElementById('report-modal').classList.add('open');
loadReportSubjects();
}
document.getElementById('open-report-btn').addEventListener('click', openReportModal);
document.getElementById('report-close').addEventListener('click', () => {
document.getElementById('report-modal').classList.remove('open');
});
document.getElementById('report-modal').addEventListener('click', e => {
if (e.target.id === 'report-modal') e.currentTarget.classList.remove('open');
});
document.getElementById('report-search').addEventListener('input', renderReportSubjects);
document.getElementById('report-subjects').addEventListener('change', e => {
const cb = e.target.closest('input[type=checkbox]');
if (!cb) return;
if (cb.checked) reportSelected.add(cb.dataset.key);
else reportSelected.delete(cb.dataset.key);
updateReportSelCount();
});
document.getElementById('report-select-all').addEventListener('click', () => {
const term = document.getElementById('report-search').value.trim().toLowerCase();
reportSubjects.filter(s => s.name.toLowerCase().includes(term))
.forEach(s => reportSelected.add(s.key));
renderReportSubjects();
});
document.getElementById('report-select-none').addEventListener('click', () => {
reportSelected.clear();
renderReportSubjects();
});
document.getElementById('report-run-btn').addEventListener('click', runReport);
document.getElementById('report-csv-btn').addEventListener('click', () => {
window.location = '/api/report?' + reportParams({ format: 'csv' }).toString();
});
// -------- Wire up --------
document.getElementById('refresh-btn').addEventListener('click', loadData);
document.getElementById('sync-btn').addEventListener('click', syncUsers);