Files
busylight_modular/app.py
T
christianandClaude Opus 5 b02e6702e4 fix: Stamm-Besetzer der Zentrale kommt bei Freigabe zurueck
"Zurueck auf mein Tuerschild" hat die Zentrale nur ausgeschaltet, statt
ihren eigentlichen Besetzer wieder darauf zu setzen. Wer normalerweise am
Empfang sitzt, blieb nach einer Vertretung ohne Zuordnung.

Die Zentrale hat jetzt einen Stamm-Besetzer, ohne dass es dafuer eine
eigene Einstellung braucht: wer beim ersten Verdraengen keinen anderen
Platz gemerkt hat, bekommt das Zentral-Segment als Heimatplatz vermerkt.
Wird die Zentrale wieder frei, kommt er automatisch zurueck - egal wie
viele Vertretungen sich zwischenzeitlich abgewechselt haben.

Der Vermerk des Stamm-Besetzers bleibt dabei dauerhaft stehen, anders als
bei einer Vertretung: die Zentrale ist sein normaler Platz, nicht eine
Station, aus der er zurueckkehrt. Klickt er selbst "zurueck auf mein
Tuerschild", aendert sich deshalb nichts und die Seite erklaert es.

Damit gilt in beide Richtungen dieselbe Regel: wird ein Platz frei, kommt
zurueck, fuer wen er gemerkt ist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 08:47:21 +00:00

1366 lines
55 KiB
Python

#!/usr/bin/env python3
"""Busylight WLED Controller - Web Application."""
import hashlib
import json
import logging
import os
import secrets
import shutil
import sys
from contextlib import asynccontextmanager
from datetime import datetime
from fastapi import FastAPI, Request, Form, UploadFile, File, BackgroundTasks
from fastapi.responses import HTMLResponse, RedirectResponse, FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from apscheduler.schedulers.background import BackgroundScheduler
import database
import scheduler
from graph_client import GraphAPI
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger(__name__)
bg_scheduler = BackgroundScheduler()
@asynccontextmanager
async def lifespan(application: FastAPI):
database.init_db()
# Init Graph client
try:
scheduler.graph = GraphAPI()
except Exception as e:
logger.error("Graph-Client konnte nicht initialisiert werden: %s", e)
# Verzeichnisse erstellen
os.makedirs(BACKUP_DIR, exist_ok=True)
os.makedirs(WLED_BACKUP_DIR, exist_ok=True)
os.makedirs(FIRMWARE_DIR, exist_ok=True)
# Start background polling
interval = int(database.get_setting("poll_interval", "15"))
bg_scheduler.add_job(scheduler.poll_status, "interval", seconds=interval, id="poll")
bg_scheduler.add_job(scheduler.check_devices, "interval", seconds=240, id="device_check")
bg_scheduler.add_job(auto_backup_db, "cron", hour=2, minute=0, id="auto_backup")
bg_scheduler.add_job(auto_backup_wled, "cron", day=1, hour=2, minute=5, id="auto_backup_wled")
# Initial device check (nicht blockierend)
bg_scheduler.add_job(scheduler.check_devices, "date", id="device_check_init")
bg_scheduler.start()
logger.info("Polling gestartet (Intervall: %ds)", interval)
yield
bg_scheduler.shutdown()
BACKUP_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "backups")
WLED_BACKUP_DIR = os.path.join(BACKUP_DIR, "wled")
FIRMWARE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "firmware")
def auto_backup_db():
"""Create daily DB backup. Keeps last 14 backups."""
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
dst = os.path.join(BACKUP_DIR, f"busylight_{ts}.db")
shutil.copy2(database.DB_PATH, dst)
# Keep only last 14
backups = sorted(
[f for f in os.listdir(BACKUP_DIR) if f.startswith("busylight_") and f.endswith(".db")],
reverse=True,
)
for old in backups[14:]:
os.remove(os.path.join(BACKUP_DIR, old))
logger.info("Auto-Backup erstellt: %s", dst)
def auto_backup_wled():
"""Backup all WLED configs. Keeps last 14 per device."""
from wled_client import get_wled_config
conn = database.get_db()
devices = conn.execute("SELECT id, hostname FROM wled_devices").fetchall()
conn.close()
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
count = 0
for dev in devices:
config = get_wled_config(dev["hostname"])
if config:
safe_name = dev["hostname"].replace(".", "_")
path = os.path.join(WLED_BACKUP_DIR, f"{safe_name}_{ts}.json")
with open(path, "w") as f:
json.dump(config, f, indent=2)
count += 1
# Keep only last 14 per device
prefix = safe_name + "_"
device_backups = sorted(
[f for f in os.listdir(WLED_BACKUP_DIR) if f.startswith(prefix)],
reverse=True,
)
for old in device_backups[14:]:
os.remove(os.path.join(WLED_BACKUP_DIR, old))
logger.info("WLED Auto-Backup: %d Geraete gesichert", count)
app = FastAPI(title="Busylight Manager", lifespan=lifespan)
app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates")
SESSION_TOKEN = secrets.token_hex(32) # Regenerated on restart
def _hash_password(pw):
return hashlib.sha256(pw.encode()).hexdigest()
def _is_logged_in(request):
return request.cookies.get("session") == SESSION_TOKEN
def _require_login(request):
"""Redirect to login if not authenticated. Returns RedirectResponse or None."""
if not _is_logged_in(request):
# Check if a password is set
admin_pw = database.get_setting("admin_password", "")
if admin_pw:
return RedirectResponse(f"/login?next={request.url.path}", status_code=303)
return None
def _ctx(request, page, **kwargs):
"""Build template context with common variables."""
authenticated = scheduler.graph is not None and scheduler.graph.is_authenticated()
logged_in = _is_logged_in(request)
return request, f"{page}.html", {"page": page, "authenticated": authenticated, "logged_in": logged_in, **kwargs}
# ── Login ──────────────────────────────────────────────────────────────────
@app.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
next_url = request.query_params.get("next", "/")
return templates.TemplateResponse(request, "login.html", {
"page": "login", "next": next_url,
"error": request.query_params.get("error"),
})
@app.post("/login")
async def login_submit(
request: Request,
password: str = Form(...),
next: str = Form("/"),
):
admin_pw = database.get_setting("admin_password", "")
if admin_pw and _hash_password(password) == admin_pw:
response = RedirectResponse(next, status_code=303)
response.set_cookie("session", SESSION_TOKEN, httponly=True, max_age=86400)
return response
return RedirectResponse("/login?error=1&next=" + next, status_code=303)
@app.get("/logout")
async def logout():
response = RedirectResponse("/", status_code=303)
response.delete_cookie("session")
return response
# ── Dashboard ──────────────────────────────────────────────────────────────
@app.get("/", response_class=HTMLResponse)
async def dashboard(request: Request):
conn = database.get_db()
devices = conn.execute("""
SELECT d.id, d.hostname, d.name, d.segment_count
FROM wled_devices d ORDER BY d.name, d.hostname
""").fetchall()
device_data = []
for dev in devices:
segments = []
for seg_idx in range(dev["segment_count"]):
row = conn.execute("""
SELECT u.display_name, u.azure_id,
s.last_attendance_status, s.last_teams_status,
COALESCE(s.phone_active, 0) as phone_active,
cr_att.r as ar, cr_att.g as ag, cr_att.b as ab,
cr_teams.r as tr, cr_teams.g as tg, cr_teams.b as tb
FROM assignments a
JOIN users u ON u.azure_id = a.user_azure_id
LEFT JOIN state s ON s.user_azure_id = u.azure_id
LEFT JOIN color_rules cr_att ON cr_att.status = s.last_attendance_status
LEFT JOIN color_rules cr_teams ON cr_teams.status = s.last_teams_status
WHERE a.wled_device_id = ? AND a.segment = ?
""", (dev["id"], seg_idx)).fetchone()
if row:
phone = row["phone_active"]
if phone:
pc = conn.execute("SELECT r,g,b FROM color_rules WHERE status='phone_active'").fetchone()
r, g, b = (pc["r"], pc["g"], pc["b"]) if pc else (255, 0, 0)
elif row["last_attendance_status"] == "anwesend" and row["tr"] is not None:
r, g, b = row["tr"], row["tg"], row["tb"]
elif row["ar"] is not None:
r, g, b = row["ar"], row["ag"], row["ab"]
else:
r, g, b = 128, 128, 128
segments.append({
"index": seg_idx,
"user": row["display_name"],
"attendance": row["last_attendance_status"] or "-",
"teams": row["last_teams_status"] or "-",
"phone": phone,
"r": r, "g": g, "b": b,
})
else:
segments.append({
"index": seg_idx, "user": None,
"attendance": "-", "teams": "-",
"r": 128, "g": 128, "b": 128,
})
device_data.append({
"id": dev["id"],
"hostname": dev["hostname"],
"name": dev["name"] or dev["hostname"],
"segments": segments,
})
conn.close()
return templates.TemplateResponse(*_ctx(request, "dashboard", devices=device_data))
# ── Users ──────────────────────────────────────────────────────────────────
@app.get("/users", response_class=HTMLResponse)
async def users_page(request: Request):
redir = _require_login(request)
if redir: return redir
conn = database.get_db()
users = conn.execute("""
SELECT u.*, a.id as assignment_id
FROM users u LEFT JOIN assignments a ON a.user_azure_id = u.azure_id
ORDER BY u.active DESC, u.display_name
""").fetchall()
conn.close()
return templates.TemplateResponse(*_ctx(request, "users", users=users))
@app.post("/api/users/sync")
async def sync_users():
if scheduler.graph is None or not scheduler.graph.is_authenticated():
return RedirectResponse("/users?error=not_authenticated", status_code=303)
azure_users = scheduler.graph.get_users()
conn = database.get_db()
count = 0
for au in azure_users:
existing = conn.execute(
"SELECT azure_id FROM users WHERE azure_id = ?", (au["id"],)
).fetchone()
if not existing:
conn.execute(
"INSERT INTO users (azure_id, display_name, email) VALUES (?, ?, ?)",
(au["id"], au.get("displayName", ""), au.get("mail", "")),
)
count += 1
else:
conn.execute(
"UPDATE users SET display_name=?, email=? WHERE azure_id=?",
(au.get("displayName", ""), au.get("mail", ""), au["id"]),
)
conn.commit()
conn.close()
return RedirectResponse(f"/users?synced={count}", status_code=303)
@app.post("/api/users/{azure_id}/update")
async def update_user(
azure_id: str,
personal_nr: str = Form(""),
phone_extension: str = Form(""),
active: str = Form("0"),
):
conn = database.get_db()
conn.execute(
"UPDATE users SET personal_nr=?, phone_extension=?, active=? WHERE azure_id=?",
(personal_nr, phone_extension, 1 if active == "1" else 0, azure_id),
)
conn.commit()
conn.close()
return RedirectResponse("/users", status_code=303)
@app.post("/api/users/{azure_id}/toggle")
async def toggle_user(azure_id: str):
conn = database.get_db()
conn.execute(
"UPDATE users SET active = CASE WHEN active=1 THEN 0 ELSE 1 END WHERE azure_id=?",
(azure_id,),
)
conn.commit()
conn.close()
return RedirectResponse("/users", status_code=303)
# ── Devices ────────────────────────────────────────────────────────────────
@app.get("/devices", response_class=HTMLResponse)
async def devices_page(request: Request):
redir = _require_login(request)
if redir: return redir
conn = database.get_db()
devices_raw = conn.execute(
"SELECT * FROM wled_devices ORDER BY name, hostname"
).fetchall()
# Letzte Aktivitaet pro Device aus activity_log
devices = []
for dev in devices_raw:
last_act = conn.execute("""
SELECT MAX(l.timestamp) as la
FROM activity_log l
WHERE l.wled_url LIKE '%' || ? || '%'
""", (dev["hostname"],)).fetchone()
d = dict(dev)
d["last_activity"] = last_act["la"] if last_act and last_act["la"] else None
# Segment assignments for this device
d["segments"] = conn.execute("""
SELECT a.segment, u.display_name
FROM assignments a
JOIN users u ON u.azure_id = a.user_azure_id
WHERE a.wled_device_id = ?
ORDER BY a.segment
""", (dev["id"],)).fetchall()
devices.append(d)
last_check_row = conn.execute(
"SELECT MAX(last_check) as lc FROM wled_devices WHERE last_check IS NOT NULL"
).fetchone()
conn.close()
last_check = last_check_row["lc"] if last_check_row and last_check_row["lc"] else None
# Firmware files
firmwares = []
if os.path.exists(FIRMWARE_DIR):
for f in sorted(os.listdir(FIRMWARE_DIR), reverse=True):
if f.endswith(".bin"):
size = os.path.getsize(os.path.join(FIRMWARE_DIR, f)) / 1024
firmwares.append({"name": f, "size": f"{size:.0f} KB"})
return templates.TemplateResponse(*_ctx(request, "devices",
devices=devices, last_check=last_check, firmwares=firmwares))
@app.post("/api/devices")
async def add_device(
hostname: str = Form(...),
name: str = Form(""),
segment_count: int = Form(2),
):
conn = database.get_db()
conn.execute(
"INSERT OR IGNORE INTO wled_devices (hostname, name, segment_count) VALUES (?, ?, ?)",
(hostname, name or hostname, segment_count),
)
conn.commit()
conn.close()
return RedirectResponse("/devices", status_code=303)
@app.post("/api/devices/{device_id}/update")
async def update_device(
device_id: int,
hostname: str = Form(...),
name: str = Form(""),
segment_count: int = Form(2),
):
conn = database.get_db()
conn.execute(
"UPDATE wled_devices SET hostname=?, name=?, segment_count=? WHERE id=?",
(hostname, name, segment_count, device_id),
)
conn.commit()
conn.close()
return RedirectResponse("/devices", status_code=303)
@app.post("/api/devices/{device_id}/delete")
async def delete_device(device_id: int):
conn = database.get_db()
conn.execute("DELETE FROM wled_devices WHERE id=?", (device_id,))
conn.commit()
conn.close()
return RedirectResponse("/devices", status_code=303)
@app.post("/api/devices/check-all")
async def check_all_devices():
scheduler.check_devices()
return RedirectResponse("/devices", status_code=303)
@app.post("/api/devices/{device_id}/check")
async def check_single_device(device_id: int):
from wled_client import get_wled_info
conn = database.get_db()
dev = conn.execute("SELECT hostname FROM wled_devices WHERE id=?", (device_id,)).fetchone()
if dev:
from datetime import datetime
info = get_wled_info(dev["hostname"])
if info.get("online"):
conn.execute("""
UPDATE wled_devices SET online=1, leds=?, version=?, mac=?, last_check=? WHERE id=?
""", (info.get("leds", 0), info.get("version", ""), info.get("mac", ""), datetime.now(), device_id))
else:
conn.execute("""
UPDATE wled_devices SET online=0, last_check=? WHERE id=?
""", (datetime.now(), device_id))
conn.commit()
conn.close()
return RedirectResponse("/devices", status_code=303)
# ── Assignments ────────────────────────────────────────────────────────────
@app.get("/assignments", response_class=HTMLResponse)
async def assignments_page(request: Request):
redir = _require_login(request)
if redir: return redir
conn = database.get_db()
assignments = conn.execute("""
SELECT a.id, a.segment, u.display_name, u.azure_id,
d.hostname, d.name as device_name, d.id as device_id
FROM assignments a
JOIN users u ON u.azure_id = a.user_azure_id
JOIN wled_devices d ON d.id = a.wled_device_id
ORDER BY d.name, a.segment
""").fetchall()
users = conn.execute(
"SELECT azure_id, display_name FROM users WHERE active=1 ORDER BY display_name"
).fetchall()
devices = conn.execute(
"SELECT id, hostname, name, segment_count FROM wled_devices ORDER BY name"
).fetchall()
conn.close()
return templates.TemplateResponse(*_ctx(request, "assignments",
assignments=assignments, users=users, devices=devices))
@app.post("/api/assignments")
async def create_assignment(
user_azure_id: str = Form(...),
wled_device_id: int = Form(...),
segment: int = Form(0),
):
conn = database.get_db()
# Remove existing assignment for this user
conn.execute("DELETE FROM assignments WHERE user_azure_id=?", (user_azure_id,))
conn.execute(
"INSERT OR REPLACE INTO assignments (user_azure_id, wled_device_id, segment) VALUES (?, ?, ?)",
(user_azure_id, wled_device_id, segment),
)
# Eine ausdrueckliche Admin-Zuordnung setzt den fuer die Zentrale gemerkten
# Heimatplatz ausser Kraft, sonst wuerde die Rueckkehr auf einen veralteten
# Platz zeigen.
conn.execute("DELETE FROM home_assignments WHERE user_azure_id=?", (user_azure_id,))
conn.commit()
conn.close()
return RedirectResponse("/assignments", status_code=303)
@app.post("/api/assignments/{assignment_id}/delete")
async def delete_assignment(assignment_id: int):
conn = database.get_db()
# Der fuer die Zentrale gemerkte Heimatplatz bleibt hier absichtlich stehen:
# wer von der Zentrale entfernt wird, soll noch zurueckkehren koennen.
conn.execute("DELETE FROM assignments WHERE id=?", (assignment_id,))
conn.commit()
conn.close()
return RedirectResponse("/assignments", status_code=303)
# ── Zentrale (Self-Service per Durchwahl) ──────────────────────
def _selfservice_target(conn):
"""Konfiguriertes Zentral-Geraet + Segment, oder None wenn nicht eingerichtet."""
raw_id = database.get_setting("selfservice_device_id", "")
if not raw_id:
return None
try:
device_id = int(raw_id)
except (TypeError, ValueError):
return None
dev = conn.execute(
"SELECT id, hostname, name FROM wled_devices WHERE id = ?", (device_id,)
).fetchone()
if not dev:
return None
try:
segment = int(database.get_setting("selfservice_segment", "0"))
except (TypeError, ValueError):
segment = 0
return {
"id": dev["id"],
"hostname": dev["hostname"],
"name": dev["name"] or dev["hostname"],
"segment": segment,
}
def _user_by_extension(extension, conn):
"""Aktiven Benutzer anhand der Telefon-Durchwahl finden."""
ext = (extension or "").strip()
if not ext:
return None
return conn.execute("""
SELECT azure_id, display_name, phone_extension
FROM users
WHERE active = 1 AND TRIM(phone_extension) = ?
""", (ext,)).fetchone()
def _zentrale_view(request, conn, target, extension="", user=None, error=None, message=None):
"""Kontext fuer die Self-Service-Seite zusammenbauen."""
occupant = None
if target:
occupant = conn.execute("""
SELECT u.display_name
FROM assignments a JOIN users u ON u.azure_id = a.user_azure_id
WHERE a.wled_device_id = ? AND a.segment = ?
""", (target["id"], target["segment"])).fetchone()
home = None
is_here = False
if user:
home = conn.execute("""
SELECT h.segment, d.name, d.hostname
FROM home_assignments h JOIN wled_devices d ON d.id = h.wled_device_id
WHERE h.user_azure_id = ?
""", (user["azure_id"],)).fetchone()
if target:
is_here = conn.execute("""
SELECT 1 FROM assignments
WHERE user_azure_id = ? AND wled_device_id = ? AND segment = ?
""", (user["azure_id"], target["id"], target["segment"])).fetchone() is not None
return templates.TemplateResponse(request, "zentrale.html", {
"page": "zentrale",
"target": target,
"occupant": occupant["display_name"] if occupant else None,
"extension": extension,
"user": user,
"home": home,
"is_here": is_here,
"error": error,
"message": message,
})
def _zentrale_owner_row(conn, target):
"""Heimateintrag, der auf das Zentral-Segment zeigt - der Stamm-Besetzer."""
if not target:
return None
return conn.execute("""
SELECT h.user_azure_id, u.display_name
FROM home_assignments h JOIN users u ON u.azure_id = h.user_azure_id
WHERE h.wled_device_id = ? AND h.segment = ? AND u.active = 1
AND NOT EXISTS (SELECT 1 FROM assignments a WHERE a.user_azure_id = h.user_azure_id)
ORDER BY h.created_at, h.user_azure_id
LIMIT 1
""", (target["id"], target["segment"])).fetchone()
def _restore_zentrale_owner(conn, target):
"""Stamm-Besetzer zurueckholen, sobald das Zentral-Segment frei ist.
Als Stamm-Besetzer gilt, fuer wen das Zentral-Segment als Heimatplatz
gemerkt ist. Sein Eintrag bleibt dabei absichtlich stehen: die Zentrale
ist sein normaler Platz, nicht eine Vertretung, aus der er zurueckkehrt.
"""
if not target:
return None
busy = conn.execute(
"SELECT 1 FROM assignments WHERE wled_device_id = ? AND segment = ?",
(target["id"], target["segment"]),
).fetchone()
if busy:
return None
owner = _zentrale_owner_row(conn, target)
if not owner:
return None
conn.execute("""
INSERT INTO assignments (user_azure_id, wled_device_id, segment) VALUES (?, ?, ?)
""", (owner["user_azure_id"], target["id"], target["segment"]))
return owner
@app.get("/zentrale", response_class=HTMLResponse)
async def zentrale_page(request: Request):
"""Self-Service-Seite fuer den Empfangsbereich. Absichtlich ohne Admin-Login."""
conn = database.get_db()
try:
return _zentrale_view(request, conn, _selfservice_target(conn))
finally:
conn.close()
@app.get("/zentrale/{extension}", response_class=HTMLResponse)
async def zentrale_page_for_extension(request: Request, extension: str):
"""Direktlink pro Mitarbeiter (z.B. als QR-Code). Zeigt nur an, aendert nichts."""
conn = database.get_db()
try:
target = _selfservice_target(conn)
user = _user_by_extension(extension, conn)
error = None if user else f"Durchwahl {extension} ist keinem aktiven Benutzer zugeordnet."
return _zentrale_view(request, conn, target, extension=extension, user=user, error=error)
finally:
conn.close()
@app.post("/api/zentrale/uebernehmen", response_class=HTMLResponse)
async def zentrale_takeover(request: Request, extension: str = Form(...)):
"""Mitarbeiter setzt sich selbst auf das Busylight der Zentrale."""
conn = database.get_db()
try:
target = _selfservice_target(conn)
if not target:
return _zentrale_view(request, conn, target, extension=extension,
error="Die Zentrale ist noch nicht eingerichtet. Bitte den Administrator informieren.")
user = _user_by_extension(extension, conn)
if not user:
return _zentrale_view(request, conn, target, extension=extension,
error=f"Durchwahl {extension} ist keinem aktiven Benutzer zugeordnet.")
# Sitzt der Benutzer schon dort? Dann nur das Licht nachziehen.
already = conn.execute("""
SELECT 1 FROM assignments
WHERE user_azure_id = ? AND wled_device_id = ? AND segment = ?
""", (user["azure_id"], target["id"], target["segment"])).fetchone()
if already:
scheduler.push_user_light(user["azure_id"], user["display_name"],
target["hostname"], target["segment"], conn)
return _zentrale_view(request, conn, target, extension=extension, user=user,
message=f"{user['display_name']} ist bereits auf der Zentrale eingetragen.")
# Bisheriger Platz des Benutzers wird als Heimat-Tuerschild gemerkt
own = conn.execute("""
SELECT a.wled_device_id, a.segment, d.hostname, d.name
FROM assignments a JOIN wled_devices d ON d.id = a.wled_device_id
WHERE a.user_azure_id = ?
""", (user["azure_id"],)).fetchone()
predecessor = conn.execute("""
SELECT u.azure_id, u.display_name
FROM assignments a JOIN users u ON u.azure_id = a.user_azure_id
WHERE a.wled_device_id = ? AND a.segment = ?
""", (target["id"], target["segment"])).fetchone()
if own:
conn.execute("""
INSERT OR REPLACE INTO home_assignments (user_azure_id, wled_device_id, segment)
VALUES (?, ?, ?)
""", (user["azure_id"], own["wled_device_id"], own["segment"]))
# Zentral-Segment freimachen, eigene Zuordnung loesen, neu eintragen.
# Beide DELETEs sind noetig: UNIQUE(wled_device_id, segment) und UNIQUE(user_azure_id).
conn.execute("DELETE FROM assignments WHERE wled_device_id = ? AND segment = ?",
(target["id"], target["segment"]))
conn.execute("DELETE FROM assignments WHERE user_azure_id = ?", (user["azure_id"],))
conn.execute("""
INSERT INTO assignments (user_azure_id, wled_device_id, segment) VALUES (?, ?, ?)
""", (user["azure_id"], target["id"], target["segment"]))
# Vorgaenger automatisch auf sein Tuerschild zurueckschicken. Ohne das muesste
# er erst selbst "zurueck" klicken, bevor die Uebernahme fuer ihn stimmt - und
# bis dahin haette er gar kein Licht.
pred_back = None
pred_blocked = None
pred_waiting = False
if predecessor and predecessor["azure_id"] != user["azure_id"]:
pred_home = conn.execute("""
SELECT h.wled_device_id, h.segment, d.hostname, d.name
FROM home_assignments h JOIN wled_devices d ON d.id = h.wled_device_id
WHERE h.user_azure_id = ?
""", (predecessor["azure_id"],)).fetchone()
is_owner = pred_home and (pred_home["wled_device_id"], pred_home["segment"]) \
== (target["id"], target["segment"])
if is_owner:
# Sein Stammplatz IST die Zentrale, die jetzt der Nachfolger hat.
# Der Heimateintrag bleibt stehen, damit er zurueckkommt, sobald
# die Zentrale wieder frei wird.
pred_waiting = True
elif pred_home:
taken = conn.execute("""
SELECT u.display_name FROM assignments a JOIN users u ON u.azure_id = a.user_azure_id
WHERE a.wled_device_id = ? AND a.segment = ?
""", (pred_home["wled_device_id"], pred_home["segment"])).fetchone()
if taken:
pred_blocked = taken["display_name"]
else:
conn.execute("""
INSERT INTO assignments (user_azure_id, wled_device_id, segment) VALUES (?, ?, ?)
""", (predecessor["azure_id"], pred_home["wled_device_id"], pred_home["segment"]))
conn.execute("DELETE FROM home_assignments WHERE user_azure_id = ?",
(predecessor["azure_id"],))
pred_back = pred_home
else:
# Kein anderer Platz gemerkt: die Zentrale ist sein Stammplatz.
# Merken, damit er automatisch zurueckkommt, wenn sie frei wird.
claimed = conn.execute("""
SELECT 1 FROM home_assignments
WHERE wled_device_id = ? AND segment = ? AND user_azure_id != ?
""", (target["id"], target["segment"], predecessor["azure_id"])).fetchone()
if not claimed:
conn.execute("""
INSERT OR REPLACE INTO home_assignments (user_azure_id, wled_device_id, segment)
VALUES (?, ?, ?)
""", (predecessor["azure_id"], target["id"], target["segment"]))
pred_waiting = True
conn.commit()
# Licht sofort setzen - das Polling wuerde bei unveraendertem Status nichts senden
scheduler.push_user_light(user["azure_id"], user["display_name"],
target["hostname"], target["segment"], conn)
# Das eigene Segment nur ausschalten, wenn nicht gerade der Vorgaenger
# dorthin zurueckgekehrt ist - sonst wuerde sein Licht sofort wieder ausgehen.
if own and not (pred_back
and pred_back["wled_device_id"] == own["wled_device_id"]
and pred_back["segment"] == own["segment"]):
scheduler.clear_segment(own["hostname"], own["segment"], conn)
if pred_back:
scheduler.push_user_light(predecessor["azure_id"], predecessor["display_name"],
pred_back["hostname"], pred_back["segment"], conn)
logger.info("Zentrale uebernommen von %s (Durchwahl %s), Vorgaenger: %s (%s)",
user["display_name"], extension,
predecessor["display_name"] if predecessor else "-",
"zurueck auf eigenem Tuerschild" if pred_back
else f"Tuerschild belegt von {pred_blocked}" if pred_blocked
else "wartet auf die Zentrale" if pred_waiting
else "kein Tuerschild gemerkt")
msg = f"{user['display_name']} sitzt jetzt auf der Zentrale ({target['name']})."
if own:
msg += f" Das eigene Tuerschild ({own['name'] or own['hostname']}) ist bis zur Rueckkehr ausgeschaltet."
if pred_back:
msg += (f" {predecessor['display_name']} wurde auf das eigene Tuerschild "
f"({pred_back['name'] or pred_back['hostname']}) zurueckgesetzt.")
elif pred_blocked:
msg += (f" {predecessor['display_name']} konnte nicht zurueckgesetzt werden, "
f"weil das eigene Tuerschild inzwischen {pred_blocked} zugeordnet ist.")
elif pred_waiting:
msg += (f" {predecessor['display_name']} gehoert auf die Zentrale und kommt automatisch "
"zurueck, sobald sie wieder freigegeben wird.")
elif predecessor and predecessor["azure_id"] != user["azure_id"]:
msg += (f" Fuer {predecessor['display_name']} war kein eigenes Tuerschild gemerkt, "
"er ist jetzt ohne Zuordnung.")
return _zentrale_view(request, conn, target, extension=extension, user=user, message=msg)
finally:
conn.close()
@app.post("/api/zentrale/verlassen", response_class=HTMLResponse)
async def zentrale_release(request: Request, extension: str = Form(...)):
"""Mitarbeiter verlaesst die Zentrale und geht auf sein Heimat-Tuerschild zurueck."""
conn = database.get_db()
try:
target = _selfservice_target(conn)
user = _user_by_extension(extension, conn)
if not user:
return _zentrale_view(request, conn, target, extension=extension,
error=f"Durchwahl {extension} ist keinem aktiven Benutzer zugeordnet.")
was_here = bool(target) and conn.execute("""
SELECT 1 FROM assignments
WHERE user_azure_id = ? AND wled_device_id = ? AND segment = ?
""", (user["azure_id"], target["id"], target["segment"])).fetchone() is not None
home = conn.execute("""
SELECT h.wled_device_id, h.segment, d.hostname, d.name
FROM home_assignments h JOIN wled_devices d ON d.id = h.wled_device_id
WHERE h.user_azure_id = ?
""", (user["azure_id"],)).fetchone()
if not home:
if not was_here:
return _zentrale_view(request, conn, target, extension=extension, user=user,
error="Es ist kein Heimat-Tuerschild gespeichert und du sitzt nicht auf der Zentrale.")
# Nur von der Zentrale abmelden, es gibt keinen Platz zum Zurueckgehen
conn.execute("DELETE FROM assignments WHERE user_azure_id = ?", (user["azure_id"],))
returning = _restore_zentrale_owner(conn, target)
conn.commit()
if returning:
scheduler.push_user_light(returning["user_azure_id"], returning["display_name"],
target["hostname"], target["segment"], conn)
else:
scheduler.clear_segment(target["hostname"], target["segment"], conn)
logger.info("Zentrale verlassen von %s (kein Heimat-Tuerschild), zurueck auf der Zentrale: %s",
user["display_name"], returning["display_name"] if returning else "-")
msg = f"{user['display_name']} ist von der Zentrale abgemeldet. Ein eigenes Tuerschild war nicht gespeichert."
if returning:
msg += f" {returning['display_name']} sitzt wieder auf der Zentrale."
return _zentrale_view(request, conn, target, extension=extension, user=user, message=msg)
# Der Stamm-Besetzer der Zentrale hat keinen anderen Platz, auf den er
# zurueckgehen koennte - sein Heimatplatz IST die Zentrale.
if target and (home["wled_device_id"], home["segment"]) == (target["id"], target["segment"]):
if was_here:
return _zentrale_view(request, conn, target, extension=extension, user=user,
message=("Die Zentrale ist dein eigener Platz - du sitzt schon dort. "
"Es gibt nichts zurueckzusetzen."))
return _zentrale_view(request, conn, target, extension=extension, user=user,
error=("Die Zentrale ist dein eigener Platz, aktuell hat sie jemand anderes. "
"Mit \"Zentrale uebernehmen\" holst du sie zurueck."))
# Ist der Heimatplatz inzwischen von jemand anderem belegt?
blocker = conn.execute("""
SELECT u.display_name
FROM assignments a JOIN users u ON u.azure_id = a.user_azure_id
WHERE a.wled_device_id = ? AND a.segment = ? AND a.user_azure_id != ?
""", (home["wled_device_id"], home["segment"], user["azure_id"])).fetchone()
if blocker:
return _zentrale_view(request, conn, target, extension=extension, user=user,
error=(f"Das eigene Tuerschild ({home['name'] or home['hostname']}, Segment {home['segment']}) "
f"ist inzwischen {blocker['display_name']} zugeordnet. "
"Bitte den Administrator ansprechen - es wurde nichts geaendert."))
conn.execute("DELETE FROM assignments WHERE user_azure_id = ?", (user["azure_id"],))
conn.execute("""
INSERT INTO assignments (user_azure_id, wled_device_id, segment) VALUES (?, ?, ?)
""", (user["azure_id"], home["wled_device_id"], home["segment"]))
conn.execute("DELETE FROM home_assignments WHERE user_azure_id = ?", (user["azure_id"],))
# Die Zentrale ist jetzt frei - ihr Stamm-Besetzer kommt zurueck.
returning = _restore_zentrale_owner(conn, target) if was_here else None
conn.commit()
scheduler.push_user_light(user["azure_id"], user["display_name"],
home["hostname"], home["segment"], conn)
if was_here:
if returning:
scheduler.push_user_light(returning["user_azure_id"], returning["display_name"],
target["hostname"], target["segment"], conn)
else:
scheduler.clear_segment(target["hostname"], target["segment"], conn)
logger.info("Zentrale verlassen von %s, zurueck auf %s Segment %s; Zentrale nun: %s",
user["display_name"], home["hostname"], home["segment"],
returning["display_name"] if returning else "frei")
msg = (f"{user['display_name']} ist zurueck auf dem eigenen Tuerschild "
f"({home['name'] or home['hostname']}, Segment {home['segment']}).")
if returning:
msg += f" {returning['display_name']} sitzt wieder auf der Zentrale."
return _zentrale_view(request, conn, target, extension=extension, user=user, message=msg)
finally:
conn.close()
# ── Color Rules ────────────────────────────────────────────────────────────
@app.get("/colors", response_class=HTMLResponse)
async def colors_page(request: Request):
redir = _require_login(request)
if redir: return redir
conn = database.get_db()
rules = conn.execute("SELECT * FROM color_rules ORDER BY status").fetchall()
conn.close()
return templates.TemplateResponse(*_ctx(request, "colors", rules=rules))
@app.post("/api/colors")
async def update_colors(request: Request):
form = await request.form()
conn = database.get_db()
statuses = form.getlist("status")
for status in statuses:
color_hex = form.get(f"color_{status}", "#000000")
# Convert hex to RGB
color_hex = color_hex.lstrip("#")
r = int(color_hex[0:2], 16)
g = int(color_hex[2:4], 16)
b = int(color_hex[4:6], 16)
effect = form.get(f"effect_{status}", "")
conn.execute(
"UPDATE color_rules SET r=?, g=?, b=?, effect=? WHERE status=?",
(r, g, b, effect, status),
)
conn.commit()
conn.close()
return RedirectResponse("/colors?saved=1", status_code=303)
@app.post("/api/colors/add")
async def add_color(
status: str = Form(...),
color: str = Form("#00ff00"),
effect: str = Form(""),
):
color_hex = color.lstrip("#")
r = int(color_hex[0:2], 16)
g = int(color_hex[2:4], 16)
b = int(color_hex[4:6], 16)
conn = database.get_db()
conn.execute(
"INSERT OR REPLACE INTO color_rules (status, r, g, b, effect) VALUES (?, ?, ?, ?, ?)",
(status, r, g, b, effect),
)
conn.commit()
conn.close()
return RedirectResponse("/colors", status_code=303)
# ── Settings ───────────────────────────────────────────────────────────────
@app.get("/settings", response_class=HTMLResponse)
async def settings_page(request: Request):
redir = _require_login(request)
if redir: return redir
settings = database.get_all_settings()
conn = database.get_db()
devices = conn.execute(
"SELECT id, hostname, name, segment_count FROM wled_devices ORDER BY name"
).fetchall()
conn.close()
return templates.TemplateResponse(*_ctx(request, "settings", settings=settings, devices=devices))
@app.post("/api/settings")
async def update_settings(request: Request):
form = await request.form()
keys = [
"azure_client_id", "azure_client_secret", "azure_tenant_id",
"azure_auth_tenant", "azure_scopes", "poll_interval",
"attendance_csv_path", "attendance_status_anwesend", "attendance_status_abwesend",
"attendance_status_homeoffice",
"selfservice_device_id", "selfservice_segment",
]
for key in keys:
value = form.get(key, "")
database.set_setting(key, value)
# Auth Tenant immer gleich Tenant ID setzen
tenant_id = form.get("azure_tenant_id", "")
if tenant_id:
database.set_setting("azure_auth_tenant", tenant_id)
# Admin password
new_pw = form.get("admin_password", "")
if new_pw:
database.set_setting("admin_password", _hash_password(new_pw))
logger.info("Admin-Passwort geaendert")
# Update scheduler interval
try:
interval = int(form.get("poll_interval", "15"))
bg_scheduler.reschedule_job("poll", trigger="interval", seconds=interval)
logger.info("Polling-Intervall geaendert auf %ds", interval)
except Exception as e:
logger.error("Fehler beim Aendern des Intervalls: %s", e)
return RedirectResponse("/settings?saved=1", status_code=303)
# ── Log ────────────────────────────────────────────────────────────────────
@app.get("/log", response_class=HTMLResponse)
async def log_page(request: Request):
redir = _require_login(request)
if redir: return redir
conn = database.get_db()
logs = conn.execute(
"SELECT * FROM activity_log ORDER BY id DESC LIMIT 200"
).fetchall()
conn.close()
return templates.TemplateResponse(*_ctx(request, "log", logs=logs))
# ── API Status (for dashboard auto-refresh) ───────────────────────────────
@app.get("/api/status")
async def api_status():
conn = database.get_db()
states = conn.execute("""
SELECT u.display_name, u.azure_id, s.last_attendance_status, s.last_teams_status,
s.updated_at, d.hostname, a.segment
FROM state s
JOIN users u ON u.azure_id = s.user_azure_id
LEFT JOIN assignments a ON a.user_azure_id = u.azure_id
LEFT JOIN wled_devices d ON d.id = a.wled_device_id
ORDER BY u.display_name
""").fetchall()
conn.close()
return [dict(s) for s in states]
@app.post("/api/poll")
async def trigger_poll():
"""Trigger a manual poll."""
scheduler.poll_status()
return RedirectResponse("/", status_code=303)
# ── Backup ─────────────────────────────────────────────────────────────────
@app.get("/backup", response_class=HTMLResponse)
async def backup_page(request: Request):
redir = _require_login(request)
if redir: return redir
# Portal backups
db_backups = []
if os.path.exists(BACKUP_DIR):
for f in sorted(os.listdir(BACKUP_DIR), reverse=True):
if f.startswith("busylight_") and f.endswith(".db"):
path = os.path.join(BACKUP_DIR, f)
size = os.path.getsize(path) / 1024
# Parse date from filename: busylight_YYYYMMDD_HHMMSS.db
try:
ts = f.replace("busylight_", "").replace(".db", "")
dt = datetime.strptime(ts, "%Y%m%d_%H%M%S")
date_display = dt.strftime("%d.%m.%Y %H:%M:%S")
except ValueError:
date_display = f
db_backups.append({"name": f, "date": date_display, "size": f"{size:.0f} KB"})
# WLED config backups grouped by device
wled_by_device = {}
if os.path.exists(WLED_BACKUP_DIR):
for f in sorted(os.listdir(WLED_BACKUP_DIR), reverse=True):
if f.endswith(".json"):
path = os.path.join(WLED_BACKUP_DIR, f)
size = os.path.getsize(path) / 1024
# Extract device name and timestamp from filename
# Format: hostname_parts_YYYYMMDD_HHMMSS.json
parts = f.rsplit("_", 2)
if len(parts) >= 3:
device_key = parts[0]
date_str = parts[1]
time_str = parts[2].replace(".json", "")
try:
dt = datetime.strptime(date_str + time_str, "%Y%m%d%H%M%S")
date_display = dt.strftime("%d.%m.%Y %H:%M:%S")
except ValueError:
date_display = f"{date_str} {time_str}"
else:
device_key = f.replace(".json", "")
date_display = "-"
hostname = device_key.replace("_", ".")
if hostname not in wled_by_device:
wled_by_device[hostname] = []
wled_by_device[hostname].append({
"name": f, "size": f"{size:.1f} KB", "date": date_display,
})
conn = database.get_db()
devices = conn.execute("SELECT id, hostname, name FROM wled_devices ORDER BY name, hostname").fetchall()
conn.close()
return templates.TemplateResponse(*_ctx(request, "backup",
db_backups=db_backups, wled_by_device=wled_by_device, devices=devices))
@app.get("/api/backup/db/download")
async def download_db():
"""Download current database."""
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
return FileResponse(
database.DB_PATH,
media_type="application/octet-stream",
filename=f"busylight_{ts}.db",
)
@app.post("/api/backup/db/create")
async def create_db_backup():
"""Create a manual DB backup."""
auto_backup_db()
return RedirectResponse("/backup?db_created=1", status_code=303)
@app.get("/api/backup/db/{filename}")
async def download_db_backup(filename: str):
"""Download a specific DB backup."""
path = os.path.join(BACKUP_DIR, filename)
if not os.path.exists(path) or ".." in filename:
return RedirectResponse("/backup", status_code=303)
return FileResponse(path, media_type="application/octet-stream", filename=filename)
@app.post("/api/backup/db/{filename}/delete")
async def delete_db_backup(filename: str):
path = os.path.join(BACKUP_DIR, filename)
if os.path.exists(path) and ".." not in filename:
os.remove(path)
return RedirectResponse("/backup", status_code=303)
@app.post("/api/backup/wled/all")
async def backup_all_wled():
"""Backup config of all WLED devices."""
from wled_client import get_wled_config
conn = database.get_db()
devices = conn.execute("SELECT id, hostname, name FROM wled_devices").fetchall()
conn.close()
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
count = 0
for dev in devices:
config = get_wled_config(dev["hostname"])
if config:
safe_name = dev["hostname"].replace(".", "_")
path = os.path.join(WLED_BACKUP_DIR, f"{safe_name}_{ts}.json")
with open(path, "w") as f:
json.dump(config, f, indent=2)
count += 1
return RedirectResponse(f"/backup?wled_backed_up={count}", status_code=303)
@app.post("/api/backup/wled/{device_id}")
async def backup_single_wled(device_id: int):
"""Backup config of a single WLED device."""
from wled_client import get_wled_config
conn = database.get_db()
dev = conn.execute("SELECT hostname FROM wled_devices WHERE id=?", (device_id,)).fetchone()
conn.close()
if not dev:
return RedirectResponse("/backup", status_code=303)
config = get_wled_config(dev["hostname"])
if config:
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
safe_name = dev["hostname"].replace(".", "_")
path = os.path.join(WLED_BACKUP_DIR, f"{safe_name}_{ts}.json")
with open(path, "w") as f:
json.dump(config, f, indent=2)
return RedirectResponse("/backup?wled_backed_up=1", status_code=303)
@app.get("/api/backup/wled/download/{filename}")
async def download_wled_backup(filename: str):
path = os.path.join(WLED_BACKUP_DIR, filename)
if not os.path.exists(path) or ".." in filename:
return RedirectResponse("/backup", status_code=303)
return FileResponse(path, media_type="application/json", filename=filename)
@app.post("/api/backup/wled/delete/{filename}")
async def delete_wled_backup(filename: str):
path = os.path.join(WLED_BACKUP_DIR, filename)
if os.path.exists(path) and ".." not in filename:
os.remove(path)
return RedirectResponse("/backup", status_code=303)
@app.post("/api/backup/wled/restore/{filename}")
async def restore_wled_backup(filename: str, device_id: int = Form(...)):
"""Restore a WLED config backup to a device."""
from wled_client import restore_wled_config
path = os.path.join(WLED_BACKUP_DIR, filename)
if not os.path.exists(path) or ".." in filename:
return RedirectResponse("/backup", status_code=303)
conn = database.get_db()
dev = conn.execute("SELECT hostname FROM wled_devices WHERE id=?", (device_id,)).fetchone()
conn.close()
if not dev:
return RedirectResponse("/backup", status_code=303)
with open(path) as f:
config = json.load(f)
success, msg = restore_wled_config(dev["hostname"], config)
return RedirectResponse(f"/backup?restored={'1' if success else '0'}", status_code=303)
# ── Firmware Update ─────────────────────────────────────────────────────────
@app.post("/api/firmware/upload")
async def upload_firmware(firmware: UploadFile = File(...)):
"""Upload a WLED firmware .bin file."""
if not firmware.filename.endswith(".bin"):
return RedirectResponse("/devices?fw_error=format", status_code=303)
path = os.path.join(FIRMWARE_DIR, firmware.filename)
with open(path, "wb") as f:
content = await firmware.read()
f.write(content)
logger.info("Firmware hochgeladen: %s (%d KB)", firmware.filename, len(content) // 1024)
return RedirectResponse("/devices?fw_uploaded=1", status_code=303)
@app.post("/api/firmware/{device_id}/update")
async def ota_update_device(device_id: int, firmware_file: str = Form(...)):
"""OTA update a single WLED device. Backs up config first."""
from wled_client import get_wled_config, ota_update_wled
conn = database.get_db()
dev = conn.execute("SELECT hostname, name FROM wled_devices WHERE id=?", (device_id,)).fetchone()
conn.close()
if not dev:
return RedirectResponse("/devices?fw_error=notfound", status_code=303)
fw_path = os.path.join(FIRMWARE_DIR, firmware_file)
if not os.path.exists(fw_path) or ".." in firmware_file:
return RedirectResponse("/devices?fw_error=nofile", status_code=303)
# Auto config backup before update
config = get_wled_config(dev["hostname"])
if config:
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
safe_name = dev["hostname"].replace(".", "_")
backup_path = os.path.join(WLED_BACKUP_DIR, f"{safe_name}_{ts}_pre_update.json")
with open(backup_path, "w") as f:
json.dump(config, f, indent=2)
logger.info("Pre-Update Backup: %s", backup_path)
# Push firmware
success, msg = ota_update_wled(dev["hostname"], fw_path)
logger.info("OTA Update %s: %s - %s", dev["hostname"], "OK" if success else "FEHLER", msg)
if success:
return RedirectResponse(f"/devices?fw_updated={dev['hostname']}", status_code=303)
else:
return RedirectResponse(f"/devices?fw_error=failed", status_code=303)
@app.post("/api/firmware/{filename}/delete")
async def delete_firmware(filename: str):
path = os.path.join(FIRMWARE_DIR, filename)
if os.path.exists(path) and ".." not in filename:
os.remove(path)
return RedirectResponse("/devices", status_code=303)
# ── Azure Auth (Web-basiert) ──────────────────────────────────────────────
# Global state for device code flow
_auth_state = {"status": "idle", "user_code": None, "verification_uri": None, "error": None}
def _run_device_code_auth():
"""Background task: run device code flow and save auth record."""
from azure.identity import DeviceCodeCredential, TokenCachePersistenceOptions
from database import get_setting, set_setting
client_id = get_setting("azure_client_id")
tenant_id = get_setting("azure_auth_tenant", "common")
scopes = get_setting("azure_scopes", "User.Read").split()
if not client_id:
_auth_state["status"] = "error"
_auth_state["error"] = "Anwendungs-ID (Client) nicht konfiguriert"
return
def prompt_callback(verification_uri, user_code, expires_on):
_auth_state["user_code"] = user_code
_auth_state["verification_uri"] = verification_uri
_auth_state["status"] = "waiting"
try:
cred = DeviceCodeCredential(
client_id=client_id,
tenant_id=tenant_id,
cache_persistence_options=TokenCachePersistenceOptions(
name="busylight", allow_unencrypted_storage=True
),
prompt_callback=prompt_callback,
)
record = cred.authenticate(tenant_id=tenant_id, scopes=scopes)
set_setting("azure_auth_record", record.serialize())
_auth_state["status"] = "success"
# Reinit graph client
try:
scheduler.graph = GraphAPI()
logger.info("Graph-Client nach Web-Auth neu initialisiert")
except Exception as e:
logger.error("Graph-Client Reinit fehlgeschlagen: %s", e)
except Exception as e:
_auth_state["status"] = "error"
_auth_state["error"] = str(e)
logger.error("Web-Auth fehlgeschlagen: %s", e)
@app.post("/api/azure/auth")
async def start_azure_auth():
"""Start device code flow in background."""
import threading
_auth_state["status"] = "starting"
_auth_state["user_code"] = None
_auth_state["verification_uri"] = None
_auth_state["error"] = None
# Clear old auth record
database.set_setting("azure_auth_record", "")
thread = threading.Thread(target=_run_device_code_auth, daemon=True)
thread.start()
return JSONResponse({"status": "starting"})
@app.get("/api/azure/auth/status")
async def azure_auth_status():
"""Poll auth state from frontend."""
return JSONResponse(_auth_state)
# ── CLI Auth Command ───────────────────────────────────────────────────────
def cli_auth():
"""Run Azure device code authentication from terminal."""
database.init_db()
# Clear old auth record to force fresh device code flow
database.set_setting("azure_auth_record", "")
print("Starte Azure Device Code Authentifizierung...")
print("Bitte den angezeigten Code im Browser eingeben.\n")
graph = GraphAPI()
graph.authenticate()
print("\nAuthentifizierung erfolgreich!")
if __name__ == "__main__":
import uvicorn
import threading
if len(sys.argv) > 1 and sys.argv[1] == "--auth":
cli_auth()
else:
# Start webhook server on port 8081 in background thread
from webhook import webhook_app
def run_webhook():
uvicorn.run(webhook_app, host="0.0.0.0", port=8081, log_level="info")
webhook_thread = threading.Thread(target=run_webhook, daemon=True)
webhook_thread.start()
logger.info("Webhook-Server gestartet auf Port 8081")
# Main web UI on port 8080
uvicorn.run(app, host="0.0.0.0", port=8080)