Die Zustandsseite /zentrale/{durchwahl} war ein Fehlgriff. Sie wurde als
"Adresse fuer den Alltag" eingefuehrt und war damit eine zweite URL, die
man sich merken muss. Wer sie nicht hatte, kam nicht mehr aus der Zentrale
heraus: auf /zentrale sah man nur, dass jemand anderes eingetragen ist,
ohne einen Weg zum Freigeben.
Jetzt gibt es genau eine Adresse. Auf /zentrale die eigene Durchwahl
eintragen, und die Seite zeigt die Knoepfe, die fuer diese Person gerade
gelten - derselbe Weg fuehrt zum Uebernehmen und zum Freigeben. Das
Durchwahl-Feld ist immer da, auch wenn die Zentrale besetzt ist, und der
Hilfetext nennt beide Wege.
- /zentrale/{durchwahl} bleibt als Kurzform fuer QR-Codes, leitet aber nur
auf /zentrale?durchwahl=... um
- alle Aktionen leiten auf /zentrale zurueck statt auf eine Unterseite
- der Knopf heisst "Zentrale freigeben" statt "Zurueck auf mein
Tuerschild", weil das die Sicht des Bedienenden ist
- kein Hinweis mehr, sich eine persoenliche Adresse als Lesezeichen zu
merken - das war die Ursache der Verwirrung
Die Bestaetigungsstufe vor dem Verdraengen ist wieder raus, sie war nicht
gewuenscht. Die Unterscheidung der drei Faelle am Knopf bleibt, damit
sichtbar ist, was mit dem bisherigen Besetzer passiert.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1441 lines
56 KiB
Python
1441 lines
56 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 urllib.parse import quote
|
|
|
|
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)
|
|
os.makedirs(CUSTOM_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")
|
|
# Kundenspezifische Dateien (Logo). Liegt bewusst ausserhalb der Versionierung,
|
|
# damit ein git pull das Kundenlogo nicht ueberschreibt.
|
|
CUSTOM_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static", "custom")
|
|
LOGO_EXTENSIONS = (".png", ".jpg", ".jpeg", ".svg", ".webp", ".gif")
|
|
LOGO_MAX_BYTES = 2 * 1024 * 1024
|
|
|
|
|
|
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
|
|
|
|
DEFAULT_LOGO = "/static/logo.png"
|
|
|
|
|
|
def _logo_url():
|
|
"""URL des anzuzeigenden Logos - kundenspezifisch, sonst das mitgelieferte.
|
|
|
|
Als Jinja-Global registriert, damit auch Seiten ohne _ctx() (Login,
|
|
Zentrale) dasselbe Logo zeigen.
|
|
"""
|
|
name = database.get_setting("logo_file", "")
|
|
if name:
|
|
# Kein Pfad, nur Dateiname - der Wert kommt aus der eigenen DB, aber
|
|
# ein Traversal soll auch dann nicht moeglich sein.
|
|
name = os.path.basename(name)
|
|
if os.path.exists(os.path.join(CUSTOM_DIR, name)):
|
|
return f"/static/custom/{name}"
|
|
return DEFAULT_LOGO
|
|
|
|
|
|
templates.env.globals["logo_url"] = _logo_url
|
|
|
|
|
|
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_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
|
|
|
|
|
|
def _zentrale_state(conn, target, user):
|
|
"""Alles, was die Self-Service-Seite ueber die aktuelle Lage wissen muss.
|
|
|
|
Wird bei jedem GET frisch berechnet. Die Aktionen leiten deshalb nach dem
|
|
Absenden auf /zentrale/{durchwahl} um: die Seite zeigt dann den echten
|
|
Zustand und nicht das Ergebnis eines abgeschickten Formulars.
|
|
"""
|
|
st = {
|
|
"occupant": None, "is_here": False, "home": None,
|
|
"home_blocker": None, "is_owner": False, "owner_waiting": None,
|
|
"occupant_is_owner": False, "occupant_has_home": False,
|
|
}
|
|
if not target:
|
|
return st
|
|
|
|
st["occupant"] = 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()
|
|
|
|
owner = _zentrale_owner_row(conn, target)
|
|
if owner and (not st["occupant"] or owner["user_azure_id"] != st["occupant"]["azure_id"]):
|
|
st["owner_waiting"] = owner["display_name"]
|
|
|
|
# Sitzt der aktuelle Besetzer auf seinem Stammplatz? Dann kommt er nach einer
|
|
# Uebernahme von allein zurueck - das gehoert auf den Knopf geschrieben.
|
|
if st["occupant"]:
|
|
occ_home = conn.execute("""
|
|
SELECT wled_device_id, segment FROM home_assignments WHERE user_azure_id = ?
|
|
""", (st["occupant"]["azure_id"],)).fetchone()
|
|
st["occupant_has_home"] = occ_home is not None
|
|
st["occupant_is_owner"] = bool(occ_home) and \
|
|
(occ_home["wled_device_id"], occ_home["segment"]) == (target["id"], target["segment"])
|
|
|
|
if not user:
|
|
return st
|
|
|
|
st["is_here"] = bool(st["occupant"] and st["occupant"]["azure_id"] == user["azure_id"])
|
|
|
|
st["home"] = conn.execute("""
|
|
SELECT h.wled_device_id, 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 st["home"]:
|
|
st["is_owner"] = (st["home"]["wled_device_id"], st["home"]["segment"]) \
|
|
== (target["id"], target["segment"])
|
|
if not st["is_owner"]:
|
|
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 != ?
|
|
""", (st["home"]["wled_device_id"], st["home"]["segment"], user["azure_id"])).fetchone()
|
|
st["home_blocker"] = blocker["display_name"] if blocker else None
|
|
return st
|
|
|
|
|
|
def _zentrale_page(request, conn, target, extension="", user=None, done=None):
|
|
"""Self-Service-Seite rendern."""
|
|
st = _zentrale_state(conn, target, user)
|
|
return templates.TemplateResponse(request, "zentrale.html", {
|
|
"page": "zentrale", "target": target, "extension": extension,
|
|
"user": user, "done": done, **st,
|
|
})
|
|
|
|
|
|
def _zentrale_redirect(extension, done):
|
|
"""Nach jeder Aktion zurueck auf /zentrale (POST/Redirect/GET).
|
|
|
|
Es gibt absichtlich nur diese eine Adresse: wer die Zentrale freigeben will,
|
|
soll sich keine zweite URL gemerkt haben muessen.
|
|
"""
|
|
ext = quote((extension or "").strip(), safe="")
|
|
if not ext:
|
|
return RedirectResponse(f"/zentrale?done={done}", status_code=303)
|
|
return RedirectResponse(f"/zentrale?durchwahl={ext}&done={done}", status_code=303)
|
|
|
|
|
|
@app.get("/zentrale", response_class=HTMLResponse)
|
|
async def zentrale_page(request: Request):
|
|
"""Self-Service-Seite fuer den Empfangsbereich. Absichtlich ohne Admin-Login.
|
|
|
|
Die eine Adresse fuer alles: Durchwahl eintragen, und die Seite zeigt die
|
|
Knoepfe, die fuer diese Person gerade gelten - Uebernehmen oder Freigeben.
|
|
"""
|
|
typed = (request.query_params.get("durchwahl") or "").strip()
|
|
done = request.query_params.get("done")
|
|
|
|
conn = database.get_db()
|
|
try:
|
|
target = _selfservice_target(conn)
|
|
user = _user_by_extension(typed, conn) if typed else None
|
|
if typed and not user and not done:
|
|
done = "err_unbekannt"
|
|
return _zentrale_page(request, conn, target, extension=typed, user=user, done=done)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@app.get("/zentrale/{extension}")
|
|
async def zentrale_page_for_extension(extension: str):
|
|
"""Kurzform fuer QR-Codes. Leitet auf die eine Adresse um."""
|
|
return RedirectResponse(f"/zentrale?durchwahl={quote(extension, safe='')}", status_code=303)
|
|
|
|
|
|
@app.post("/api/zentrale/uebernehmen")
|
|
async def zentrale_takeover(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_redirect(extension, "err_nicht_eingerichtet")
|
|
|
|
user = _user_by_extension(extension, conn)
|
|
if not user:
|
|
return _zentrale_redirect(extension, "err_unbekannt")
|
|
|
|
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_redirect(extension, "bereits")
|
|
|
|
# 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
|
|
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.
|
|
pass
|
|
elif pred_home:
|
|
taken = conn.execute("""
|
|
SELECT 1 FROM assignments
|
|
WHERE wled_device_id = ? AND segment = ?
|
|
""", (pred_home["wled_device_id"], pred_home["segment"])).fetchone()
|
|
if not taken:
|
|
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"]))
|
|
|
|
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",
|
|
user["display_name"], extension,
|
|
predecessor["display_name"] if predecessor else "-")
|
|
return _zentrale_redirect(extension, "uebernommen")
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@app.post("/api/zentrale/verlassen")
|
|
async def zentrale_release(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_redirect(extension, "err_unbekannt")
|
|
|
|
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_redirect(extension, "err_nichts_zu_tun")
|
|
# 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: %s",
|
|
user["display_name"], returning["display_name"] if returning else "-")
|
|
return _zentrale_redirect(extension, "abgemeldet")
|
|
|
|
# Der Stamm-Besetzer hat keinen anderen Platz - sein Heimatplatz IST die Zentrale
|
|
if target and (home["wled_device_id"], home["segment"]) == (target["id"], target["segment"]):
|
|
return _zentrale_redirect(extension, "stammplatz_hier" if was_here else "err_stammplatz_fremd")
|
|
|
|
# Ist der Heimatplatz inzwischen von jemand anderem belegt?
|
|
blocker = conn.execute("""
|
|
SELECT 1 FROM assignments
|
|
WHERE wled_device_id = ? AND segment = ? AND user_azure_id != ?
|
|
""", (home["wled_device_id"], home["segment"], user["azure_id"])).fetchone()
|
|
if blocker:
|
|
return _zentrale_redirect(extension, "err_heimat_belegt")
|
|
|
|
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")
|
|
return _zentrale_redirect(extension, "zurueck")
|
|
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)
|
|
|
|
|
|
@app.post("/api/settings/logo")
|
|
async def upload_logo(request: Request, logo: UploadFile = File(...)):
|
|
"""Kundenspezifisches Logo hochladen."""
|
|
redir = _require_login(request)
|
|
if redir: return redir
|
|
|
|
ext = os.path.splitext(logo.filename or "")[1].lower()
|
|
if ext not in LOGO_EXTENSIONS:
|
|
return RedirectResponse("/settings?logo_error=format", status_code=303)
|
|
|
|
content = await logo.read()
|
|
if not content:
|
|
return RedirectResponse("/settings?logo_error=empty", status_code=303)
|
|
if len(content) > LOGO_MAX_BYTES:
|
|
return RedirectResponse("/settings?logo_error=size", status_code=303)
|
|
|
|
os.makedirs(CUSTOM_DIR, exist_ok=True)
|
|
# Nur ein Logo behalten, sonst bleiben alte Endungen liegen
|
|
for old in os.listdir(CUSTOM_DIR):
|
|
if old.startswith("logo."):
|
|
try:
|
|
os.remove(os.path.join(CUSTOM_DIR, old))
|
|
except OSError:
|
|
pass
|
|
|
|
filename = f"logo{ext}"
|
|
with open(os.path.join(CUSTOM_DIR, filename), "wb") as f:
|
|
f.write(content)
|
|
database.set_setting("logo_file", filename)
|
|
logger.info("Kundenlogo hochgeladen: %s (%d KB)", filename, len(content) // 1024)
|
|
return RedirectResponse("/settings?logo_saved=1", status_code=303)
|
|
|
|
|
|
@app.post("/api/settings/logo/reset")
|
|
async def reset_logo(request: Request):
|
|
"""Zurueck auf das mitgelieferte Logo."""
|
|
redir = _require_login(request)
|
|
if redir: return redir
|
|
|
|
if os.path.isdir(CUSTOM_DIR):
|
|
for old in os.listdir(CUSTOM_DIR):
|
|
if old.startswith("logo."):
|
|
try:
|
|
os.remove(os.path.join(CUSTOM_DIR, old))
|
|
except OSError:
|
|
pass
|
|
database.set_setting("logo_file", "")
|
|
logger.info("Kundenlogo entfernt, Standardlogo aktiv")
|
|
return RedirectResponse("/settings?logo_reset=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)
|