diff --git a/README.md b/README.md index cee8b0f..b34ddaa 100644 --- a/README.md +++ b/README.md @@ -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/` | 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//` | `filtered` (bool) | Hide / unhide an actor from the attendance table | | `GET` | `/api/persons` | β€” | List merged people with their members | diff --git a/app.py b/app.py index 5972fb5..0b97d09 100644 --- a/app.py +++ b/app.py @@ -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 # --------------------------------------------------------------------------- diff --git a/static/index.html b/static/index.html index a1f07be..0daeccf 100644 --- a/static/index.html +++ b/static/index.html @@ -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 @@ + @@ -404,6 +444,52 @@ + + +