feat: Zentrale per Durchwahl selbst besetzen

Das Busylight im Empfangsbereich wird wechselnd besetzt. Mitarbeiter
koennen sich jetzt selbst darauf setzen, ohne Admin-Passwort.

- Neue Seite /zentrale (Durchwahl eingeben) und /zentrale/{durchwahl}
  als Direktlink fuer QR-Codes. Der Direktlink zeigt nur an und aendert
  nichts, damit Link-Vorschau und Prefetch niemanden versetzen.
- Neue Tabelle home_assignments merkt das eigene Tuerschild, solange der
  Mitarbeiter in der Zentrale sitzt. Beim Verlassen wird es
  wiederhergestellt, das Zentral-Segment wird freigegeben.
- Der Vorgaenger bleibt ohne Zuordnung - er ist nicht da. Sein gemerkter
  Heimatplatz bleibt erhalten, er kann jederzeit zurueckkehren.
- Ist der Heimatplatz inzwischen belegt, wird die Rueckkehr abgelehnt
  und nichts geaendert.
- Eine ausdrueckliche Admin-Zuordnung verwirft den gemerkten Heimatplatz.
  Ein reines Loeschen der Zuordnung nicht, sonst kaeme niemand mehr von
  der Zentrale zurueck.
- Farbe wird sofort ans Geraet geschickt (scheduler.push_user_light).
  Das Polling allein reicht nicht: _process_user sendet nur bei einer
  Statusaenderung, und beim Platzwechsel bleibt der Status gleich.
- Neue Settings selfservice_device_id und selfservice_segment,
  einstellbar unter Einstellungen.
- Jeder Wechsel landet im Protokoll (zentrale / zentrale_frei).

Die Durchwahl ist eine Kennung, kein Geheimnis - im README dokumentiert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-21 08:12:42 +00:00
co-authored by Claude Opus 5
parent 65314f2338
commit 086b21dc2b
6 changed files with 554 additions and 3 deletions
+257 -1
View File
@@ -469,6 +469,10 @@ async def create_assignment(
"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)
@@ -477,12 +481,258 @@ async def create_assignment(
@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,
})
@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.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"]))
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)
if own:
scheduler.clear_segment(own["hostname"], own["segment"], conn)
logger.info("Zentrale uebernommen von %s (Durchwahl %s), Vorgaenger: %s",
user["display_name"], extension,
predecessor["display_name"] if predecessor else "-")
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."
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"],))
conn.commit()
scheduler.clear_segment(target["hostname"], target["segment"], conn)
logger.info("Zentrale verlassen von %s (kein Heimat-Tuerschild)", user["display_name"])
return _zentrale_view(request, conn, target, extension=extension, user=user,
message=f"{user['display_name']} ist von der Zentrale abgemeldet. Ein eigenes Tuerschild war nicht gespeichert.")
# 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"],))
conn.commit()
scheduler.push_user_light(user["azure_id"], user["display_name"],
home["hostname"], home["segment"], conn)
if was_here:
scheduler.clear_segment(target["hostname"], target["segment"], conn)
logger.info("Zentrale verlassen von %s, zurueck auf %s Segment %s",
user["display_name"], home["hostname"], home["segment"])
return _zentrale_view(request, conn, target, extension=extension, user=user,
message=(f"{user['display_name']} ist zurueck auf dem eigenen Tuerschild "
f"({home['name'] or home['hostname']}, Segment {home['segment']})."))
finally:
conn.close()
# ── Color Rules ────────────────────────────────────────────────────────────
@app.get("/colors", response_class=HTMLResponse)
@@ -548,7 +798,12 @@ async def settings_page(request: Request):
redir = _require_login(request)
if redir: return redir
settings = database.get_all_settings()
return templates.TemplateResponse(*_ctx(request, "settings", settings=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")
@@ -559,6 +814,7 @@ async def update_settings(request: Request):
"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, "")