This commit is contained in:
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user