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
+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
# ---------------------------------------------------------------------------