Files
unifi-access-dashboard/app.py
T

985 lines
35 KiB
Python
Raw Normal View History

2026-05-28 00:39:46 -05:00
import os, hmac, hashlib, json, logging, uuid, re
from datetime import datetime, timezone
from urllib.parse import urljoin
2026-03-04 21:43:49 -06:00
from flask import Flask, request, jsonify
import pytz, sqlite3
2026-03-04 19:42:38 -06:00
from apscheduler.schedulers.background import BackgroundScheduler
2026-03-04 21:43:49 -06:00
import requests, urllib3
2026-03-04 17:45:28 -06:00
2026-03-04 21:43:49 -06:00
urllib3.disable_warnings()
2026-03-04 19:42:38 -06:00
2026-03-04 21:43:49 -06:00
logging.basicConfig(level=logging.INFO)
log = logging.getLogger(__name__)
2026-03-04 17:45:28 -06:00
2026-03-04 21:43:49 -06:00
app = Flask(__name__, static_folder="static", static_url_path="")
2026-03-04 19:42:38 -06:00
2026-05-28 00:39:46 -05:00
DB_PATH = os.environ.get("DB_PATH", "/data/dashboard.db")
TZ = os.environ.get("TZ", "America/Chicago")
DASHBOARD_BASE_URL = os.environ.get("DASHBOARD_BASE_URL", "").rstrip("/")
2026-03-04 19:42:38 -06:00
2026-05-28 00:39:46 -05:00
# Seed values for the auto-created "Default" controller (only used on first boot
# when the controllers table is empty). After that, manage controllers via the UI.
SEED_HOST = os.environ.get("UNIFI_HOST", "")
SEED_PORT = int(os.environ.get("UNIFI_PORT", "12445"))
SEED_TOKEN = os.environ.get("UNIFI_API_TOKEN", "")
SEED_SECRET = os.environ.get("WEBHOOK_SECRET", "")
2026-03-04 22:03:30 -06:00
2026-03-04 17:45:28 -06:00
def get_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
2026-05-28 00:39:46 -05:00
conn.execute("PRAGMA foreign_keys = ON")
2026-03-04 17:45:28 -06:00
return conn
2026-03-04 22:03:30 -06:00
2026-05-28 00:39:46 -05:00
def _column_exists(db, table, column):
rows = db.execute(f"PRAGMA table_info({table})").fetchall()
return any(r["name"] == column for r in rows)
def _table_exists(db, table):
row = db.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table,)
).fetchone()
return row is not None
2026-03-04 17:45:28 -06:00
def init_db():
2026-03-04 21:43:49 -06:00
with get_db() as db:
2026-05-28 00:39:46 -05:00
db.execute(
"""
CREATE TABLE IF NOT EXISTS controllers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
host TEXT NOT NULL,
port INTEGER NOT NULL DEFAULT 12445,
api_token TEXT NOT NULL,
webhook_secret TEXT NOT NULL DEFAULT '',
webhook_id TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
last_sync_at TEXT
)
"""
)
2026-03-04 22:16:36 -06:00
db.execute(
"""
2026-03-04 21:43:49 -06:00
CREATE TABLE IF NOT EXISTS badge_events (
2026-05-28 00:39:46 -05:00
id INTEGER PRIMARY KEY AUTOINCREMENT,
controller_id TEXT,
actor_id TEXT NOT NULL,
ts TEXT NOT NULL,
date TEXT NOT NULL
2026-03-04 21:43:49 -06:00
)
2026-03-04 22:16:36 -06:00
"""
)
2026-05-28 00:39:46 -05:00
# Migrate legacy badge_events that pre-date the controller_id column.
if not _column_exists(db, "badge_events", "controller_id"):
db.execute("ALTER TABLE badge_events ADD COLUMN controller_id TEXT")
# Migrate legacy user_cache (single-PK on actor_id) to composite PK.
legacy_user_cache = _table_exists(db, "user_cache") and not _column_exists(
db, "user_cache", "controller_id"
)
if legacy_user_cache:
db.execute("ALTER TABLE user_cache RENAME TO user_cache_legacy")
2026-03-04 22:16:36 -06:00
db.execute(
"""
2026-03-04 21:43:49 -06:00
CREATE TABLE IF NOT EXISTS user_cache (
2026-05-28 00:39:46 -05:00
controller_id TEXT NOT NULL,
actor_id TEXT NOT NULL,
full_name TEXT NOT NULL,
updated_at TEXT NOT NULL,
2026-05-28 15:01:22 -05:00
filtered INTEGER NOT NULL DEFAULT 0,
2026-05-28 00:39:46 -05:00
PRIMARY KEY (controller_id, actor_id)
2026-03-04 21:43:49 -06:00
)
2026-03-04 22:16:36 -06:00
"""
)
2026-05-28 00:39:46 -05:00
2026-05-28 15:01:22 -05:00
if not _column_exists(db, "user_cache", "filtered"):
db.execute(
"ALTER TABLE user_cache ADD COLUMN filtered INTEGER NOT NULL DEFAULT 0"
)
2026-05-28 15:56:36 -05:00
db.execute(
"""
CREATE TABLE IF NOT EXISTS persons (
id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
filtered INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
)
"""
)
db.execute(
"""
CREATE TABLE IF NOT EXISTS person_members (
person_id TEXT NOT NULL,
controller_id TEXT NOT NULL,
actor_id TEXT NOT NULL,
PRIMARY KEY (controller_id, actor_id),
FOREIGN KEY (person_id) REFERENCES persons(id) ON DELETE CASCADE
)
"""
)
db.execute(
"CREATE INDEX IF NOT EXISTS idx_person_members_person ON person_members(person_id)"
)
2026-05-28 00:39:46 -05:00
# Seed a Default controller from env vars when the table is empty.
existing = db.execute("SELECT COUNT(*) AS n FROM controllers").fetchone()["n"]
default_id = None
if existing == 0 and SEED_HOST and SEED_TOKEN:
default_id = str(uuid.uuid4())
db.execute(
"""
INSERT INTO controllers
(id, name, host, port, api_token, webhook_secret, webhook_id,
enabled, created_at)
VALUES (?, 'Default', ?, ?, ?, ?, '', 1, ?)
""",
(
default_id,
SEED_HOST,
SEED_PORT,
SEED_TOKEN,
SEED_SECRET,
datetime.now(timezone.utc).isoformat(),
),
)
log.info("Seeded Default controller %s from env vars", default_id[:8])
# Backfill controller_id on legacy badge_events and user_cache rows.
if default_id is None:
row = db.execute(
"SELECT id FROM controllers ORDER BY created_at LIMIT 1"
).fetchone()
default_id = row["id"] if row else None
if default_id:
db.execute(
"UPDATE badge_events SET controller_id = ? WHERE controller_id IS NULL",
(default_id,),
)
if legacy_user_cache:
db.execute(
"""
INSERT OR IGNORE INTO user_cache
(controller_id, actor_id, full_name, updated_at)
SELECT ?, actor_id, full_name, updated_at FROM user_cache_legacy
""",
(default_id,),
)
db.execute("DROP TABLE user_cache_legacy")
2026-03-04 21:43:49 -06:00
db.commit()
2026-03-04 19:42:38 -06:00
2026-03-04 22:03:30 -06:00
2026-05-28 00:39:46 -05:00
def controller_base(host, port):
return f"https://{host}:{port}/api/v1/developer"
def fetch_controller_users(host, port, token):
r = requests.get(
f"{controller_base(host, port)}/users",
headers={"Authorization": f"Bearer {token}"},
verify=False,
timeout=10,
)
return r
def sync_controller(controller_id):
with get_db() as db:
c = db.execute(
"SELECT * FROM controllers WHERE id = ? AND enabled = 1", (controller_id,)
).fetchone()
if not c:
return 0
2026-03-04 19:42:38 -06:00
try:
2026-05-28 00:39:46 -05:00
r = fetch_controller_users(c["host"], c["port"], c["api_token"])
2026-03-04 21:43:49 -06:00
if r.status_code != 200:
2026-05-28 00:39:46 -05:00
log.warning(
"User sync failed for controller %s: %s %s",
c["name"], r.status_code, r.text[:200],
)
return 0
2026-03-04 21:43:49 -06:00
users = r.json().get("data", [])
2026-05-28 00:39:46 -05:00
except Exception as e:
log.error("sync_controller(%s) network error: %s", c["name"], e)
return 0
2026-03-04 22:16:36 -06:00
2026-05-28 00:39:46 -05:00
now_iso = datetime.now(timezone.utc).isoformat()
with get_db() as db:
for u in users:
actor_id = u.get("id")
if not actor_id:
continue
full_name = (u.get("full_name") or "").strip()
if not full_name:
full_name = f"{u.get('first_name','')} {u.get('last_name','')}".strip()
db.execute(
"""
INSERT INTO user_cache (controller_id, actor_id, full_name, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(controller_id, actor_id) DO UPDATE SET
full_name = excluded.full_name,
updated_at = excluded.updated_at
""",
(controller_id, actor_id, full_name or f"User {actor_id[:8]}", now_iso),
)
db.execute(
"UPDATE controllers SET last_sync_at = ? WHERE id = ?", (now_iso, controller_id)
)
db.commit()
log.info("Synced %d users from controller %s", len(users), c["name"])
return len(users)
2026-03-04 22:16:36 -06:00
2026-05-28 00:39:46 -05:00
def sync_all_controllers():
with get_db() as db:
rows = db.execute("SELECT id FROM controllers WHERE enabled = 1").fetchall()
for r in rows:
sync_controller(r["id"])
def register_webhook(host, port, token, dashboard_url, name):
r = requests.post(
f"{controller_base(host, port)}/webhooks/endpoints",
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
json={
"name": name,
"endpoint": dashboard_url,
"events": ["access.door.unlock"],
},
verify=False,
timeout=10,
)
return r
def delete_webhook(host, port, token, webhook_id):
if not webhook_id:
return None
try:
return requests.delete(
f"{controller_base(host, port)}/webhooks/endpoints/{webhook_id}",
headers={"Authorization": f"Bearer {token}"},
verify=False,
timeout=10,
)
2026-03-04 19:42:38 -06:00
except Exception as e:
2026-05-28 00:39:46 -05:00
log.warning("delete_webhook error: %s", e)
return None
2026-03-04 19:42:38 -06:00
2026-03-04 22:03:30 -06:00
2026-05-28 00:39:46 -05:00
def verify_signature(secret, payload_bytes, sig_header):
if not secret:
return True # controller has no secret stored yet — accept (LAN-trust mode)
2026-03-04 21:55:13 -06:00
if not sig_header:
return False
try:
parts = dict(p.split("=", 1) for p in sig_header.split(","))
2026-03-04 22:03:30 -06:00
timestamp = parts.get("t", "")
2026-03-04 23:04:55 -06:00
received = parts.get("v1", "")
2026-03-04 22:03:30 -06:00
if not timestamp or not received:
return False
2026-03-04 21:55:13 -06:00
signed_payload = f"{timestamp}.".encode() + payload_bytes
2026-05-28 00:39:46 -05:00
expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
2026-03-04 21:55:13 -06:00
return hmac.compare_digest(expected, received)
except Exception as e:
log.warning("Signature parse error: %s", e)
return False
2026-03-04 19:42:38 -06:00
2026-03-04 22:03:30 -06:00
2026-05-28 00:39:46 -05:00
def resolve_dashboard_base():
if DASHBOARD_BASE_URL:
return DASHBOARD_BASE_URL
return request.host_url.rstrip("/")
def controller_to_dict(row):
return {
"id": row["id"],
"name": row["name"],
"host": row["host"],
"port": row["port"],
"enabled": bool(row["enabled"]),
"has_webhook": bool(row["webhook_id"]),
"last_sync_at": row["last_sync_at"],
}
# ---------------------------------------------------------------------------
# Static + dashboard data
# ---------------------------------------------------------------------------
2026-03-04 21:43:49 -06:00
@app.route("/")
def index():
return app.send_static_file("index.html")
2026-03-04 22:03:30 -06:00
2026-05-28 00:39:46 -05:00
@app.route("/api/first-badge-status")
def first_badge_status():
date = request.args.get("date", datetime.now(pytz.timezone(TZ)).strftime("%Y-%m-%d"))
cutoff = request.args.get("cutoff", "09:00")
controller_filter = request.args.get("controller_id", "").strip() or None
2026-05-28 15:01:22 -05:00
include_filtered = request.args.get("include_filtered") == "1"
2026-05-28 00:39:46 -05:00
if not re.match(r"^\d{2}:\d{2}$", cutoff):
cutoff = "09:00"
cutoff_end = cutoff + ":59"
sql = """
SELECT
2026-05-28 15:56:36 -05:00
COALESCE(pm.person_id, b.controller_id || '|' || b.actor_id) AS group_key,
pm.person_id AS person_id,
2026-05-28 00:39:46 -05:00
COALESCE(
2026-05-28 15:56:36 -05:00
p.display_name,
2026-05-28 00:39:46 -05:00
u.full_name,
'Unknown (' || SUBSTR(b.actor_id,1,8) || '...)'
2026-05-28 15:01:22 -05:00
) AS name,
2026-05-28 15:56:36 -05:00
COALESCE(p.filtered, u.filtered, 0) AS filtered,
MIN(b.ts) AS first_ts,
MAX(b.ts) AS latest_ts,
GROUP_CONCAT(DISTINCT c.name) AS sources,
GROUP_CONCAT(DISTINCT b.controller_id) AS controller_ids,
GROUP_CONCAT(DISTINCT b.actor_id) AS actor_ids
2026-05-28 00:39:46 -05:00
FROM badge_events b
2026-05-28 15:56:36 -05:00
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
2026-05-28 00:39:46 -05:00
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 = ?
"""
params = [date]
if controller_filter:
sql += " AND b.controller_id = ?"
params.append(controller_filter)
2026-05-28 15:01:22 -05:00
if not include_filtered:
2026-05-28 15:56:36 -05:00
sql += " AND COALESCE(p.filtered, u.filtered, 0) = 0"
sql += " GROUP BY group_key ORDER BY first_ts ASC"
2026-05-28 00:39:46 -05:00
with get_db() as db:
rows = db.execute(sql, params).fetchall()
result = []
for r in rows:
first = r["first_ts"]
latest = r["latest_ts"]
2026-05-28 15:56:36 -05:00
sources_csv = r["sources"] or ""
ctrl_ids_csv = r["controller_ids"] or ""
actor_ids_csv = r["actor_ids"] or ""
sources_list = [s for s in sources_csv.split(",") if s]
ctrl_ids_list = [s for s in ctrl_ids_csv.split(",") if s]
actor_ids_list = [s for s in actor_ids_csv.split(",") if s]
2026-05-28 00:39:46 -05:00
result.append({
2026-05-28 15:56:36 -05:00
"person_id": r["person_id"],
"actor_id": actor_ids_list[0] if actor_ids_list else None,
"controller_id": ctrl_ids_list[0] if ctrl_ids_list else None,
"actor_ids": actor_ids_list,
"controller_ids": ctrl_ids_list,
2026-05-28 15:01:22 -05:00
"name": r["name"],
2026-05-28 15:56:36 -05:00
"source": sources_list[0] if sources_list else "—",
"sources": sources_list or ["—"],
"merged": bool(r["person_id"]),
2026-05-28 15:01:22 -05:00
"first_ts": first,
"latest_ts": latest if latest != first else None,
"status": "ON TIME" if first <= cutoff_end else "LATE",
"filtered": bool(r["filtered"]),
2026-05-28 00:39:46 -05:00
})
return jsonify(result)
# ---------------------------------------------------------------------------
# Webhook ingestion — per-controller endpoint, plus legacy compat alias
# ---------------------------------------------------------------------------
def _ingest_webhook(controller_id):
2026-03-04 21:43:49 -06:00
raw = request.get_data()
2026-03-04 17:45:28 -06:00
2026-05-28 00:39:46 -05:00
with get_db() as db:
c = db.execute(
"SELECT * FROM controllers WHERE id = ?", (controller_id,)
).fetchone()
if not c:
return jsonify({"error": "unknown controller"}), 404
if not verify_signature(c["webhook_secret"], raw, request.headers.get("Signature", "")):
log.warning("Signature mismatch for controller %s", c["name"])
2026-03-04 21:43:49 -06:00
return jsonify({"error": "invalid signature"}), 401
2026-03-04 17:45:28 -06:00
2026-03-04 21:43:49 -06:00
try:
payload = json.loads(raw)
except Exception:
return jsonify({"error": "bad json"}), 400
2026-03-04 19:42:38 -06:00
2026-03-04 22:03:30 -06:00
event = payload.get("event") or payload.get("event_object_id", "") or ""
2026-03-04 23:04:55 -06:00
data = payload.get("data") or {}
2026-03-04 22:03:30 -06:00
actor_obj = data.get("actor") or {}
2026-03-04 23:04:55 -06:00
actor = actor_obj.get("id")
2026-03-04 19:42:38 -06:00
2026-03-04 22:03:30 -06:00
if "access.door.unlock" not in str(event):
2026-03-04 21:43:49 -06:00
return jsonify({"status": "ignored"}), 200
if not actor:
return jsonify({"error": "no actor"}), 400
2026-03-04 19:42:38 -06:00
2026-03-04 22:03:30 -06:00
tz = pytz.timezone(TZ)
2026-03-04 23:04:55 -06:00
ts = None
top_ts_ms = payload.get("timestamp")
2026-05-28 00:39:46 -05:00
if isinstance(top_ts_ms, (int, float)) and top_ts_ms > 1e10:
2026-03-04 23:04:55 -06:00
ts = datetime.fromtimestamp(top_ts_ms / 1000.0, tz=pytz.utc)
if ts is None:
2026-05-28 00:39:46 -05:00
published = (data.get("event") or {}).get("published")
if isinstance(published, (int, float)) and published > 1e10:
2026-03-04 23:04:55 -06:00
ts = datetime.fromtimestamp(published / 1000.0, tz=pytz.utc)
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"))
break
except Exception:
pass
if ts is None:
ts = datetime.now(tz=tz)
2026-03-04 22:03:30 -06:00
ts_local = ts.astimezone(tz)
2026-03-04 23:04:55 -06:00
date = ts_local.strftime("%Y-%m-%d")
ts_str = ts_local.strftime("%H:%M:%S")
2026-03-04 17:45:28 -06:00
2026-03-04 21:43:49 -06:00
with get_db() as db:
db.execute(
2026-05-28 00:39:46 -05:00
"INSERT INTO badge_events (controller_id, actor_id, ts, date) VALUES (?, ?, ?, ?)",
(controller_id, actor, ts_str, date),
2026-03-04 21:43:49 -06:00
)
db.commit()
2026-03-04 17:45:28 -06:00
2026-05-28 00:39:46 -05:00
log.info(
"Badge-in: controller=%s actor=%s date=%s ts=%s",
c["name"], actor, date, ts_str,
)
2026-03-04 21:43:49 -06:00
return jsonify({"status": "ok"}), 200
2026-03-04 19:42:38 -06:00
2026-03-04 22:03:30 -06:00
2026-05-28 00:39:46 -05:00
@app.route("/api/unifi-access/<controller_id>", methods=["POST"])
def receive_webhook(controller_id):
return _ingest_webhook(controller_id)
2026-03-04 17:45:28 -06:00
2026-05-28 00:39:46 -05:00
@app.route("/api/unifi-access", methods=["POST"])
def receive_webhook_legacy():
"""Compat alias for installs registered before per-controller URLs existed.
Routes to the oldest controller (the env-seeded Default)."""
with get_db() as db:
row = db.execute(
"SELECT id FROM controllers ORDER BY created_at LIMIT 1"
).fetchone()
if not row:
return jsonify({"error": "no controllers configured"}), 404
return _ingest_webhook(row["id"])
# ---------------------------------------------------------------------------
# Controller management
# ---------------------------------------------------------------------------
@app.route("/api/controllers", methods=["GET"])
def list_controllers():
2026-03-04 21:43:49 -06:00
with get_db() as db:
2026-03-04 22:03:30 -06:00
rows = db.execute(
2026-05-28 00:39:46 -05:00
"SELECT * FROM controllers ORDER BY created_at"
).fetchall()
return jsonify([controller_to_dict(r) for r in rows])
@app.route("/api/controllers", methods=["POST"])
def add_controller():
body = request.get_json(silent=True) or {}
name = (body.get("name") or "").strip()
host = (body.get("host") or "").strip()
port = int(body.get("port") or 12445)
api_token = (body.get("api_token") or "").strip()
if not name or not host or not api_token:
return jsonify({"error": "name, host, and api_token are required"}), 400
controller_id = str(uuid.uuid4())
dashboard_base = resolve_dashboard_base()
endpoint_url = urljoin(dashboard_base + "/", f"api/unifi-access/{controller_id}")
try:
r = register_webhook(host, port, api_token, endpoint_url, f"Dashboard — {name}")
except Exception as e:
return jsonify({"error": f"webhook registration failed: {e}"}), 502
if r.status_code >= 300:
return jsonify({
"error": "webhook registration rejected by controller",
"status_code": r.status_code,
"response": r.text[:500],
}), 502
try:
payload = r.json()
except Exception:
return jsonify({"error": "unparseable controller response", "raw": r.text[:500]}), 502
data = payload.get("data") or {}
webhook_id = data.get("id", "")
webhook_secret = data.get("secret", "")
with get_db() as db:
db.execute(
2026-03-04 22:03:30 -06:00
"""
2026-05-28 00:39:46 -05:00
INSERT INTO controllers
(id, name, host, port, api_token, webhook_secret, webhook_id,
enabled, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?)
2026-03-04 22:03:30 -06:00
""",
2026-05-28 00:39:46 -05:00
(
controller_id, name, host, port, api_token,
webhook_secret, webhook_id,
datetime.now(timezone.utc).isoformat(),
),
)
db.commit()
2026-03-04 19:53:39 -06:00
2026-05-28 00:39:46 -05:00
sync_controller(controller_id)
with get_db() as db:
row = db.execute("SELECT * FROM controllers WHERE id = ?", (controller_id,)).fetchone()
return jsonify(controller_to_dict(row)), 201
@app.route("/api/controllers/<controller_id>", methods=["PATCH"])
def update_controller(controller_id):
body = request.get_json(silent=True) or {}
fields, values = [], []
if "name" in body:
fields.append("name = ?"); values.append((body["name"] or "").strip())
if "enabled" in body:
fields.append("enabled = ?"); values.append(1 if body["enabled"] else 0)
if not fields:
return jsonify({"error": "no updatable fields provided"}), 400
values.append(controller_id)
with get_db() as db:
cur = db.execute(
f"UPDATE controllers SET {', '.join(fields)} WHERE id = ?", values
2026-03-04 22:03:30 -06:00
)
2026-05-28 00:39:46 -05:00
db.commit()
if cur.rowcount == 0:
return jsonify({"error": "not found"}), 404
row = db.execute("SELECT * FROM controllers WHERE id = ?", (controller_id,)).fetchone()
return jsonify(controller_to_dict(row))
2026-03-04 17:45:28 -06:00
2026-05-28 00:39:46 -05:00
@app.route("/api/controllers/<controller_id>", methods=["DELETE"])
def remove_controller(controller_id):
with get_db() as db:
c = db.execute("SELECT * FROM controllers WHERE id = ?", (controller_id,)).fetchone()
if not c:
return jsonify({"error": "not found"}), 404
delete_webhook(c["host"], c["port"], c["api_token"], c["webhook_id"])
with get_db() as db:
db.execute("DELETE FROM user_cache WHERE controller_id = ?", (controller_id,))
db.execute("DELETE FROM badge_events WHERE controller_id = ?", (controller_id,))
db.execute("DELETE FROM controllers WHERE id = ?", (controller_id,))
db.commit()
return jsonify({"status": "ok"})
2026-03-04 17:45:28 -06:00
2026-03-04 22:03:30 -06:00
2026-05-28 00:39:46 -05:00
@app.route("/api/controllers/<controller_id>/test", methods=["POST"])
def test_controller(controller_id):
with get_db() as db:
c = db.execute("SELECT * FROM controllers WHERE id = ?", (controller_id,)).fetchone()
if not c:
return jsonify({"error": "not found"}), 404
try:
r = fetch_controller_users(c["host"], c["port"], c["api_token"])
ok = r.status_code == 200
user_count = len(r.json().get("data", [])) if ok else None
return jsonify({
"ok": ok,
"status_code": r.status_code,
"user_count": user_count,
"message": "Connected" if ok else r.text[:200],
})
except Exception as e:
return jsonify({"ok": False, "message": str(e)}), 200
@app.route("/api/controllers/<controller_id>/sync", methods=["POST"])
def sync_one(controller_id):
n = sync_controller(controller_id)
return jsonify({"status": "ok", "synced": n})
2026-05-28 15:01:22 -05:00
# ---------------------------------------------------------------------------
# User cache (tenant filtering)
# ---------------------------------------------------------------------------
@app.route("/api/users", methods=["GET"])
def list_users():
controller_id = request.args.get("controller_id", "").strip() or None
filtered_arg = request.args.get("filtered")
sql = """
SELECT
u.controller_id,
u.actor_id,
u.full_name,
u.updated_at,
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
WHERE 1=1
"""
params = []
if controller_id:
sql += " AND u.controller_id = ?"
params.append(controller_id)
if filtered_arg in ("0", "1"):
sql += " AND COALESCE(u.filtered, 0) = ?"
params.append(int(filtered_arg))
sql += " ORDER BY c.name, u.full_name"
with get_db() as db:
rows = db.execute(sql, params).fetchall()
return jsonify([
{
"controller_id": r["controller_id"],
"controller_name": r["controller_name"] or "—",
"actor_id": r["actor_id"],
"full_name": r["full_name"],
"updated_at": r["updated_at"],
"filtered": bool(r["filtered"]),
}
for r in rows
])
@app.route("/api/users/<controller_id>/<actor_id>", methods=["PATCH"])
def update_user(controller_id, actor_id):
body = request.get_json(silent=True) or {}
if "filtered" not in body:
return jsonify({"error": "filtered field required"}), 400
filtered = 1 if body["filtered"] else 0
with get_db() as db:
ctrl = db.execute(
"SELECT id FROM controllers WHERE id = ?", (controller_id,)
).fetchone()
if not ctrl:
return jsonify({"error": "unknown controller"}), 404
now_iso = datetime.now(timezone.utc).isoformat()
placeholder = f"User {actor_id[:8]}"
db.execute(
"""
INSERT INTO user_cache (controller_id, actor_id, full_name, updated_at, filtered)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(controller_id, actor_id) DO UPDATE SET
filtered = excluded.filtered
""",
(controller_id, actor_id, placeholder, now_iso, filtered),
)
db.commit()
row = db.execute(
"""
SELECT u.controller_id, u.actor_id, u.full_name, u.updated_at,
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
WHERE u.controller_id = ? AND u.actor_id = ?
""",
(controller_id, actor_id),
).fetchone()
return jsonify({
"controller_id": row["controller_id"],
"controller_name": row["controller_name"] or "—",
"actor_id": row["actor_id"],
"full_name": row["full_name"],
"updated_at": row["updated_at"],
"filtered": bool(row["filtered"]),
})
2026-05-28 15:56:36 -05:00
# ---------------------------------------------------------------------------
# Persons (merge identities across controllers)
# ---------------------------------------------------------------------------
def _person_to_dict(db, person_row):
members = db.execute(
"""
SELECT pm.controller_id, pm.actor_id,
c.name AS controller_name,
u.full_name
FROM person_members pm
LEFT JOIN controllers c ON c.id = pm.controller_id
LEFT JOIN user_cache u
ON u.controller_id = pm.controller_id AND u.actor_id = pm.actor_id
WHERE pm.person_id = ?
ORDER BY c.name, u.full_name
""",
(person_row["id"],),
).fetchall()
return {
"id": person_row["id"],
"display_name": person_row["display_name"],
"filtered": bool(person_row["filtered"]),
"created_at": person_row["created_at"],
"members": [
{
"controller_id": m["controller_id"],
"controller_name": m["controller_name"] or "—",
"actor_id": m["actor_id"],
"full_name": m["full_name"] or f"User {m['actor_id'][:8]}",
}
for m in members
],
}
@app.route("/api/persons", methods=["GET"])
def list_persons():
with get_db() as db:
rows = db.execute("SELECT * FROM persons ORDER BY display_name").fetchall()
return jsonify([_person_to_dict(db, r) for r in rows])
@app.route("/api/persons", methods=["POST"])
def create_person():
body = request.get_json(silent=True) or {}
name = (body.get("display_name") or "").strip()
members = body.get("members") or []
if not name:
return jsonify({"error": "display_name is required"}), 400
if not isinstance(members, list) or len(members) < 1:
return jsonify({"error": "at least one member required"}), 400
cleaned = []
for m in members:
cid = (m.get("controller_id") or "").strip()
aid = (m.get("actor_id") or "").strip()
if not cid or not aid:
return jsonify({"error": "each member needs controller_id and actor_id"}), 400
cleaned.append((cid, aid))
person_id = str(uuid.uuid4())
now_iso = datetime.now(timezone.utc).isoformat()
with get_db() as db:
existing = db.execute(
f"""
SELECT controller_id, actor_id FROM person_members
WHERE (controller_id, actor_id) IN ({",".join(["(?,?)"] * len(cleaned))})
""",
[v for pair in cleaned for v in pair],
).fetchall()
if existing:
conflicts = [f"{r['controller_id']}/{r['actor_id'][:8]}" for r in existing]
return jsonify({
"error": "some members already belong to another person",
"conflicts": conflicts,
}), 409
db.execute(
"INSERT INTO persons (id, display_name, filtered, created_at) VALUES (?, ?, 0, ?)",
(person_id, name, now_iso),
)
db.executemany(
"INSERT INTO person_members (person_id, controller_id, actor_id) VALUES (?, ?, ?)",
[(person_id, cid, aid) for cid, aid in cleaned],
)
db.commit()
row = db.execute("SELECT * FROM persons WHERE id = ?", (person_id,)).fetchone()
return jsonify(_person_to_dict(db, row)), 201
@app.route("/api/persons/<person_id>", methods=["PATCH"])
def update_person(person_id):
body = request.get_json(silent=True) or {}
fields, values = [], []
if "display_name" in body:
name = (body["display_name"] or "").strip()
if not name:
return jsonify({"error": "display_name cannot be empty"}), 400
fields.append("display_name = ?"); values.append(name)
if "filtered" in body:
fields.append("filtered = ?"); values.append(1 if body["filtered"] else 0)
if not fields:
return jsonify({"error": "no updatable fields provided"}), 400
values.append(person_id)
with get_db() as db:
cur = db.execute(
f"UPDATE persons SET {', '.join(fields)} WHERE id = ?", values
)
db.commit()
if cur.rowcount == 0:
return jsonify({"error": "not found"}), 404
row = db.execute("SELECT * FROM persons WHERE id = ?", (person_id,)).fetchone()
return jsonify(_person_to_dict(db, row))
@app.route("/api/persons/<person_id>", methods=["DELETE"])
def delete_person(person_id):
with get_db() as db:
cur = db.execute("DELETE FROM persons WHERE id = ?", (person_id,))
db.commit()
if cur.rowcount == 0:
return jsonify({"error": "not found"}), 404
return jsonify({"status": "ok"})
@app.route("/api/persons/<person_id>/members", methods=["POST"])
def add_person_member(person_id):
body = request.get_json(silent=True) or {}
cid = (body.get("controller_id") or "").strip()
aid = (body.get("actor_id") or "").strip()
if not cid or not aid:
return jsonify({"error": "controller_id and actor_id are required"}), 400
with get_db() as db:
if not db.execute("SELECT 1 FROM persons WHERE id = ?", (person_id,)).fetchone():
return jsonify({"error": "person not found"}), 404
existing = db.execute(
"SELECT person_id FROM person_members WHERE controller_id = ? AND actor_id = ?",
(cid, aid),
).fetchone()
if existing and existing["person_id"] != person_id:
return jsonify({"error": "actor already belongs to another person"}), 409
db.execute(
"""
INSERT OR IGNORE INTO person_members (person_id, controller_id, actor_id)
VALUES (?, ?, ?)
""",
(person_id, cid, aid),
)
db.commit()
row = db.execute("SELECT * FROM persons WHERE id = ?", (person_id,)).fetchone()
return jsonify(_person_to_dict(db, row))
@app.route("/api/persons/<person_id>/members/<controller_id>/<actor_id>", methods=["DELETE"])
def remove_person_member(person_id, controller_id, actor_id):
with get_db() as db:
cur = db.execute(
"""
DELETE FROM person_members
WHERE person_id = ? AND controller_id = ? AND actor_id = ?
""",
(person_id, controller_id, actor_id),
)
if cur.rowcount == 0:
return jsonify({"error": "member not found"}), 404
# If that was the last member, drop the person too.
remaining = db.execute(
"SELECT COUNT(*) AS n FROM person_members WHERE person_id = ?", (person_id,)
).fetchone()["n"]
if remaining == 0:
db.execute("DELETE FROM persons WHERE id = ?", (person_id,))
db.commit()
return jsonify({"status": "ok", "person_dissolved": True})
db.commit()
row = db.execute("SELECT * FROM persons WHERE id = ?", (person_id,)).fetchone()
return jsonify(_person_to_dict(db, row))
@app.route("/api/persons/suggestions", methods=["GET"])
def person_suggestions():
"""Return groups of (controller, actor) rows that share an exact full_name
across different controllers and are NOT already part of a person."""
with get_db() as db:
rows = db.execute(
"""
SELECT u.controller_id, u.actor_id, u.full_name,
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
AND u.full_name IS NOT NULL
AND TRIM(u.full_name) <> ''
AND u.full_name NOT LIKE 'User %'
ORDER BY LOWER(u.full_name), c.name
"""
).fetchall()
groups = {}
for r in rows:
key = r["full_name"].strip().lower()
groups.setdefault(key, []).append({
"controller_id": r["controller_id"],
"controller_name": r["controller_name"] or "—",
"actor_id": r["actor_id"],
"full_name": r["full_name"],
})
suggestions = []
for key, members in groups.items():
# Only suggest when the same name spans at least 2 distinct controllers
controllers = {m["controller_id"] for m in members}
if len(controllers) >= 2:
suggestions.append({
"display_name": members[0]["full_name"],
"members": members,
})
suggestions.sort(key=lambda s: s["display_name"].lower())
return jsonify(suggestions)
2026-05-28 00:39:46 -05:00
# ---------------------------------------------------------------------------
# Misc admin
# ---------------------------------------------------------------------------
2026-03-04 21:43:49 -06:00
@app.route("/api/sync-users")
2026-05-28 00:39:46 -05:00
def manual_sync_all():
sync_all_controllers()
2026-03-04 21:43:49 -06:00
return jsonify({"status": "synced"})
2026-03-04 19:42:38 -06:00
2026-03-04 22:03:30 -06:00
2026-03-04 21:43:49 -06:00
@app.route("/api/reset-day", methods=["DELETE"])
2026-03-04 19:53:39 -06:00
def reset_day():
2026-03-04 23:04:55 -06:00
date = request.args.get("date", datetime.now(pytz.timezone(TZ)).strftime("%Y-%m-%d"))
2026-05-28 00:39:46 -05:00
controller_id = request.args.get("controller_id", "").strip() or None
sql = "DELETE FROM badge_events WHERE date = ?"
params = [date]
if controller_id:
sql += " AND controller_id = ?"
params.append(controller_id)
2026-03-04 21:43:49 -06:00
with get_db() as db:
2026-05-28 00:39:46 -05:00
cur = db.execute(sql, params)
2026-03-04 21:43:49 -06:00
db.commit()
return jsonify({"status": "ok", "deleted": cur.rowcount, "date": date})
2026-03-04 19:42:38 -06:00
2026-03-04 22:03:30 -06:00
2026-05-28 00:39:46 -05:00
# ---------------------------------------------------------------------------
# Boot
# ---------------------------------------------------------------------------
2026-03-04 20:56:30 -06:00
with app.app_context():
init_db()
2026-05-28 00:39:46 -05:00
sync_all_controllers()
2026-03-04 20:56:30 -06:00
2026-03-04 19:42:38 -06:00
scheduler = BackgroundScheduler()
2026-05-28 00:54:54 -05:00
scheduler.add_job(sync_all_controllers, "interval", hours=1)
2026-03-04 19:42:38 -06:00
scheduler.start()
2026-03-04 17:45:28 -06:00
if __name__ == "__main__":
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 8000)))