Add files via upload

This commit is contained in:
jasonMPM
2026-03-04 23:04:55 -06:00
committed by GitHub
parent 85ca8b1b6d
commit 47138abff4

69
app.py
View File

@@ -66,10 +66,9 @@ def sync_unifi_users():
users = r.json().get("data", []) users = r.json().get("data", [])
with get_db() as db: with get_db() as db:
for u in users: for u in users:
# Use the same ID field we see in webhooks
actor_id = u.get("id") actor_id = u.get("id")
if not actor_id: if not actor_id:
continue # skip malformed entries continue
full_name = (u.get("full_name") or "").strip() full_name = (u.get("full_name") or "").strip()
if not full_name: if not full_name:
@@ -96,12 +95,6 @@ def sync_unifi_users():
def verify_signature(payload_bytes, sig_header): def verify_signature(payload_bytes, sig_header):
"""Validate UniFi Access webhook signature.
Header name : Signature
Header value: t=<unix_timestamp>,v1=<hex_hmac_sha256>
Signed data : f"{timestamp}.{raw_body}"
"""
if not WEBHOOK_SECRET: if not WEBHOOK_SECRET:
return True return True
if not sig_header: if not sig_header:
@@ -143,7 +136,7 @@ def receive_webhook():
except Exception: except Exception:
return jsonify({"error": "bad json"}), 400 return jsonify({"error": "bad json"}), 400
log.info("Webhook received: %s", json.dumps(payload)[:300]) log.info("Webhook received: %s", json.dumps(payload)[:400])
event = payload.get("event") or payload.get("event_object_id", "") or "" event = payload.get("event") or payload.get("event_object_id", "") or ""
@@ -158,19 +151,47 @@ def receive_webhook():
log.warning("Webhook has no actor id: %s", json.dumps(payload)[:300]) log.warning("Webhook has no actor id: %s", json.dumps(payload)[:300])
return jsonify({"error": "no actor"}), 400 return jsonify({"error": "no actor"}), 400
event_meta = data.get("event") or {} # ----------------------------------------------------------------
ts_ms = event_meta.get("published") # Timestamp resolution — checked in priority order:
if ts_ms: # 1. Top-level "timestamp" key (milliseconds epoch) — UniFi Access standard
ts = datetime.fromtimestamp(ts_ms / 1000.0, tz=pytz.utc) # 2. data.event.published (milliseconds epoch)
else: # 3. Top-level ISO string fields
ts_raw = ( # 4. Fall back to NOW in the configured local timezone
payload.get("timestamp") # ----------------------------------------------------------------
or payload.get("created_at")
or datetime.utcnow().isoformat()
)
ts = datetime.fromisoformat(str(ts_raw).replace("Z", "+00:00"))
tz = pytz.timezone(TZ) tz = pytz.timezone(TZ)
ts = None
# 1. Top-level timestamp (ms)
top_ts_ms = payload.get("timestamp")
if top_ts_ms and isinstance(top_ts_ms, (int, float)) and top_ts_ms > 1e10:
ts = datetime.fromtimestamp(top_ts_ms / 1000.0, tz=pytz.utc)
log.info("Timestamp source: top-level ms (%s)", top_ts_ms)
# 2. data.event.published (ms)
if ts is None:
event_meta = data.get("event") or {}
published = event_meta.get("published")
if published and isinstance(published, (int, float)) and published > 1e10:
ts = datetime.fromtimestamp(published / 1000.0, tz=pytz.utc)
log.info("Timestamp source: data.event.published (%s)", published)
# 3. ISO string fields
if ts is None:
for field in ("created_at", "time", "occurred_at"):
raw_ts = payload.get(field)
if raw_ts:
try:
ts = datetime.fromisoformat(str(raw_ts).replace("Z", "+00:00"))
log.info("Timestamp source: ISO field '%s' (%s)", field, raw_ts)
break
except Exception:
pass
# 4. Fallback — use local now so the date bucket is always correct
if ts is None:
ts = datetime.now(tz=tz)
log.warning("Timestamp source: fallback to local now")
ts_local = ts.astimezone(tz) ts_local = ts.astimezone(tz)
date = ts_local.strftime("%Y-%m-%d") date = ts_local.strftime("%Y-%m-%d")
ts_str = ts_local.strftime("%H:%M:%S") ts_str = ts_local.strftime("%H:%M:%S")
@@ -182,13 +203,13 @@ def receive_webhook():
) )
db.commit() db.commit()
log.info("Badge-in recorded: actor=%s date=%s ts=%s", actor, date, ts_str) log.info("Badge-in recorded: actor=%s date=%s ts=%s (tz=%s)", actor, date, ts_str, TZ)
return jsonify({"status": "ok"}), 200 return jsonify({"status": "ok"}), 200
@app.route("/api/first-badge-status") @app.route("/api/first-badge-status")
def first_badge_status(): def first_badge_status():
date = request.args.get("date", datetime.now().strftime("%Y-%m-%d")) date = request.args.get("date", datetime.now(pytz.timezone(TZ)).strftime("%Y-%m-%d"))
cutoff = request.args.get("cutoff", "09:00") # HH:MM cutoff = request.args.get("cutoff", "09:00") # HH:MM
with get_db() as db: with get_db() as db:
@@ -237,7 +258,7 @@ def manual_sync():
@app.route("/api/reset-day", methods=["DELETE"]) @app.route("/api/reset-day", methods=["DELETE"])
def reset_day(): def reset_day():
date = request.args.get("date", datetime.now().strftime("%Y-%m-%d")) date = request.args.get("date", datetime.now(pytz.timezone(TZ)).strftime("%Y-%m-%d"))
with get_db() as db: with get_db() as db:
cur = db.execute("DELETE FROM badge_events WHERE date = ?", (date,)) cur = db.execute("DELETE FROM badge_events WHERE date = ?", (date,))
db.commit() db.commit()