feat: rewrite busylight as FastAPI web application with SQLite
Replace the standalone Python script + config.cfg with a full web application featuring: - FastAPI backend with REST API - SQLite database (config, state, activity log separated) - Bootstrap 5 web interface for configuration - Pages: Dashboard, Users, Devices, Assignments, Color Rules, Settings, Log - APScheduler background polling for Teams presence + AIDA status - systemd service file for Linux deployment - Migration script to import existing config.cfg data Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+184
@@ -0,0 +1,184 @@
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
from database import get_db, get_setting, log_activity
|
||||
from wled_client import build_wled_url, call_wled
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Global reference to graph client (set by app.py on startup)
|
||||
graph = None
|
||||
|
||||
|
||||
def read_aida_csv(csv_path):
|
||||
"""Read AIDA status CSV. Returns dict: personal_nr -> line content."""
|
||||
if not csv_path or not os.path.exists(csv_path):
|
||||
logger.warning("AIDA CSV nicht gefunden: %s", csv_path)
|
||||
return {}
|
||||
try:
|
||||
with open(csv_path, "r", encoding="utf-8", errors="replace") as f:
|
||||
lines = f.read().splitlines()
|
||||
return lines
|
||||
except Exception as e:
|
||||
logger.error("Fehler beim Lesen der AIDA CSV: %s", e)
|
||||
return []
|
||||
|
||||
|
||||
def determine_aida_status(lines, personal_nr, settings):
|
||||
"""Determine AIDA status for a user based on their personal number."""
|
||||
if not personal_nr or not lines:
|
||||
return None
|
||||
|
||||
matching = [l for l in lines if l.startswith(personal_nr.lower())]
|
||||
if not matching:
|
||||
return None
|
||||
|
||||
line = matching[0]
|
||||
if line.endswith(settings["aida_status_abwesend"]):
|
||||
return "abwesend"
|
||||
if line.endswith(settings["aida_status_anwesend"]):
|
||||
return "anwesend"
|
||||
if line.endswith(settings["aida_status_homeoffice"]):
|
||||
return "homeoffice"
|
||||
return None
|
||||
|
||||
|
||||
def get_color_for_status(status, conn):
|
||||
"""Get RGB color for a given status from color_rules."""
|
||||
row = conn.execute(
|
||||
"SELECT r, g, b, effect FROM color_rules WHERE status = ?", (status,)
|
||||
).fetchone()
|
||||
if row:
|
||||
return dict(row)
|
||||
return {"r": 200, "g": 200, "b": 200, "effect": ""}
|
||||
|
||||
|
||||
def poll_status():
|
||||
"""Main polling function - called periodically by APScheduler."""
|
||||
global graph
|
||||
|
||||
if graph is None or not graph.is_authenticated():
|
||||
logger.warning("Graph-Client nicht authentifiziert, ueberspringe Polling")
|
||||
return
|
||||
|
||||
conn = get_db()
|
||||
try:
|
||||
settings = {}
|
||||
for row in conn.execute("SELECT key, value FROM settings").fetchall():
|
||||
settings[row["key"]] = row["value"]
|
||||
|
||||
# Get all active users with assignments
|
||||
users_with_assignments = conn.execute("""
|
||||
SELECT u.azure_id, u.display_name, u.personal_nr,
|
||||
a.segment, d.hostname,
|
||||
s.last_aida_status, s.last_teams_status
|
||||
FROM users u
|
||||
JOIN assignments a ON a.user_azure_id = u.azure_id
|
||||
JOIN wled_devices d ON d.id = a.wled_device_id
|
||||
LEFT JOIN state s ON s.user_azure_id = u.azure_id
|
||||
WHERE u.active = 1 AND u.personal_nr != ''
|
||||
""").fetchall()
|
||||
|
||||
if not users_with_assignments:
|
||||
logger.debug("Keine aktiven User mit Zuordnungen gefunden")
|
||||
return
|
||||
|
||||
# Read AIDA CSV
|
||||
aida_lines = read_aida_csv(settings.get("aida_csv_path", ""))
|
||||
|
||||
for user in users_with_assignments:
|
||||
try:
|
||||
_process_user(user, aida_lines, settings, conn)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Fehler bei User %s: %s", user["display_name"], e
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Polling Fehler: %s", e)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _process_user(user, aida_lines, settings, conn):
|
||||
"""Process a single user: check status and update WLED if needed."""
|
||||
global graph
|
||||
|
||||
azure_id = user["azure_id"]
|
||||
display_name = user["display_name"]
|
||||
personal_nr = user["personal_nr"]
|
||||
hostname = user["hostname"]
|
||||
segment = user["segment"]
|
||||
last_aida = user["last_aida_status"] or ""
|
||||
last_teams = user["last_teams_status"] or ""
|
||||
|
||||
# Determine AIDA status
|
||||
aida_status = determine_aida_status(aida_lines, personal_nr, settings)
|
||||
if aida_status is None:
|
||||
return
|
||||
|
||||
# Determine Teams status
|
||||
teams_status = ""
|
||||
if aida_status == "anwesend":
|
||||
presence = graph.get_presence(azure_id)
|
||||
if presence and "activity" in presence:
|
||||
teams_status = "teams_" + presence["activity"].lower()
|
||||
|
||||
# Check if status changed
|
||||
if aida_status == last_aida:
|
||||
if aida_status == "anwesend":
|
||||
if teams_status == last_teams:
|
||||
return # No change
|
||||
else:
|
||||
return # No change
|
||||
|
||||
# Determine color
|
||||
if aida_status == "abwesend":
|
||||
color = get_color_for_status("abwesend", conn)
|
||||
elif aida_status == "homeoffice":
|
||||
color = get_color_for_status("homeoffice", conn)
|
||||
elif aida_status == "anwesend" and teams_status:
|
||||
color = get_color_for_status(teams_status, conn)
|
||||
if not color:
|
||||
color = get_color_for_status("teams_available", conn)
|
||||
else:
|
||||
color = get_color_for_status("available", conn)
|
||||
|
||||
# Build URL and call WLED
|
||||
url = build_wled_url(
|
||||
hostname, color["r"], color["g"], color["b"],
|
||||
segment=segment, effect=color.get("effect", "")
|
||||
)
|
||||
success, result_text = call_wled(url)
|
||||
|
||||
if success:
|
||||
# Update state
|
||||
conn.execute("""
|
||||
INSERT INTO state (user_azure_id, last_aida_status, last_teams_status, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(user_azure_id)
|
||||
DO UPDATE SET last_aida_status=?, last_teams_status=?, updated_at=?
|
||||
""", (
|
||||
azure_id, aida_status, teams_status, datetime.now(),
|
||||
aida_status, teams_status, datetime.now(),
|
||||
))
|
||||
conn.commit()
|
||||
logger.info(
|
||||
"%-40s aida=%-12s teams=%-20s -> %s",
|
||||
display_name, aida_status, teams_status, result_text,
|
||||
)
|
||||
else:
|
||||
# Reset state on failure
|
||||
conn.execute("""
|
||||
INSERT INTO state (user_azure_id, last_aida_status, last_teams_status, updated_at)
|
||||
VALUES (?, '', '', ?)
|
||||
ON CONFLICT(user_azure_id)
|
||||
DO UPDATE SET last_aida_status='', last_teams_status='', updated_at=?
|
||||
""", (azure_id, datetime.now(), datetime.now()))
|
||||
conn.commit()
|
||||
|
||||
# Log activity
|
||||
log_activity(
|
||||
azure_id, display_name, aida_status, teams_status, url, result_text
|
||||
)
|
||||
Reference in New Issue
Block a user