fix: Zentrale-Seite zeigt den Zustand und den Weg zurueck

Zwei Handhabungsprobleme, die zusammengehoeren.

Auf /zentrale gab es ueberhaupt keinen Knopf zum Zurueckgeben - den fand
man nur ueber den Direktlink, den man nach einer Uebernahme in der Hand
hatte. Wer stattdessen /zentrale aufrief, konnte nur weiter uebernehmen
und sah nicht, dass erst zurueckgesetzt werden muss.

Ausserdem rendern die POST-Endpunkte ihr Ergebnis direkt. Man blieb also
auf /api/zentrale/uebernehmen stehen: eine Adresse, die beim Neuladen die
Aktion wiederholt und den Zustand nicht widerspiegelt.

Neu:

- Zwei Schritte. /zentrale fragt nur die Durchwahl ab und leitet auf
  /zentrale/{durchwahl} um. Diese Zustandsseite ist die Adresse fuer den
  Alltag, als Lesezeichen oder QR-Code, und ist gefahrlos neu ladbar.
- Die Knoepfe richten sich nach der Lage: frei -> besetzen, fremd besetzt
  -> uebernehmen samt Hinweis was mit dieser Person passiert, selbst
  besetzt -> zurueck auf mein Tuerschild, eigener Stammplatz -> kein
  Knopf, nur der Hinweis dass nichts zu tun ist.
- Alle Aktionen antworten mit POST/Redirect/GET auf die Zustandsseite und
  uebergeben nur einen kurzen Code fuer die Bestaetigung. Die Einzelheiten
  - wer sitzt drauf, wer wartet, wer blockiert den Heimatplatz - leitet
  die Seite bei jedem Aufruf frisch aus dem echten Zustand ab.
- Die allgemeine Seite nennt den aktuellen Besetzer und, falls vorhanden,
  wer bei einer Freigabe automatisch zurueckkommt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-21 09:08:08 +00:00
co-authored by Claude Opus 5
parent 09d9837dec
commit 0867221a3c
3 changed files with 329 additions and 230 deletions
+109 -115
View File
@@ -10,6 +10,7 @@ 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
@@ -559,43 +560,6 @@ def _user_by_extension(extension, conn):
""", (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:
@@ -634,45 +598,122 @@ def _restore_zentrale_owner(conn, target):
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,
}
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"]:
st["occupant_is_owner"] = conn.execute("""
SELECT 1 FROM home_assignments
WHERE user_azure_id = ? AND wled_device_id = ? AND segment = ?
""", (st["occupant"]["azure_id"], target["id"], target["segment"])).fetchone() is not None
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 die Zustandsseite (POST/Redirect/GET)."""
ext = quote((extension or "").strip(), safe="")
if not ext:
return RedirectResponse(f"/zentrale?done={done}", status_code=303)
return RedirectResponse(f"/zentrale/{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."""
# Das Formular schickt die Durchwahl per GET - auf die Personenseite umleiten,
# damit die Adresse merkbar und neu ladbar ist.
typed = (request.query_params.get("durchwahl") or "").strip()
if typed:
return RedirectResponse(f"/zentrale/{quote(typed, safe='')}", status_code=303)
conn = database.get_db()
try:
return _zentrale_view(request, conn, _selfservice_target(conn))
return _zentrale_page(request, conn, _selfservice_target(conn),
done=request.query_params.get("done"))
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."""
"""Zustandsseite pro Mitarbeiter. Zeigt nur an, geaendert wird per Knopf."""
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)
done = request.query_params.get("done") if user else "err_unbekannt"
return _zentrale_page(request, conn, target, extension=extension, user=user, done=done)
finally:
conn.close()
@app.post("/api/zentrale/uebernehmen", response_class=HTMLResponse)
async def zentrale_takeover(request: Request, extension: str = Form(...)):
@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_view(request, conn, target, extension=extension,
error="Die Zentrale ist noch nicht eingerichtet. Bitte den Administrator informieren.")
return _zentrale_redirect(extension, "err_nicht_eingerichtet")
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.")
return _zentrale_redirect(extension, "err_unbekannt")
# 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 = ?
@@ -680,8 +721,7 @@ async def zentrale_takeover(request: Request, extension: str = Form(...)):
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.")
return _zentrale_redirect(extension, "bereits")
# Bisheriger Platz des Benutzers wird als Heimat-Tuerschild gemerkt
own = conn.execute("""
@@ -715,8 +755,6 @@ async def zentrale_takeover(request: Request, extension: str = Form(...)):
# 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
@@ -731,15 +769,13 @@ async def zentrale_takeover(request: Request, extension: str = Form(...)):
# 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
pass
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 = ?
SELECT 1 FROM assignments
WHERE wled_device_id = ? AND segment = ?
""", (pred_home["wled_device_id"], pred_home["segment"])).fetchone()
if taken:
pred_blocked = taken["display_name"]
else:
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"]))
@@ -758,7 +794,6 @@ async def zentrale_takeover(request: Request, extension: str = Form(...)):
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()
@@ -777,44 +812,23 @@ async def zentrale_takeover(request: Request, extension: str = Form(...)):
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)",
logger.info("Zentrale uebernommen von %s (Durchwahl %s), Vorgaenger: %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)
predecessor["display_name"] if predecessor else "-")
return _zentrale_redirect(extension, "uebernommen")
finally:
conn.close()
@app.post("/api/zentrale/verlassen", response_class=HTMLResponse)
async def zentrale_release(request: Request, extension: str = Form(...)):
@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_view(request, conn, target, extension=extension,
error=f"Durchwahl {extension} ist keinem aktiven Benutzer zugeordnet.")
return _zentrale_redirect(extension, "err_unbekannt")
was_here = bool(target) and conn.execute("""
SELECT 1 FROM assignments
@@ -829,8 +843,7 @@ async def zentrale_release(request: Request, extension: str = Form(...)):
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.")
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)
@@ -840,35 +853,21 @@ async def zentrale_release(request: Request, extension: str = Form(...)):
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",
logger.info("Zentrale verlassen von %s (kein Heimat-Tuerschild), zurueck: %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)
return _zentrale_redirect(extension, "abgemeldet")
# Der Stamm-Besetzer der Zentrale hat keinen anderen Platz, auf den er
# zurueckgehen koennte - sein Heimatplatz IST die Zentrale.
# 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"]):
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."))
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 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 != ?
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_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."))
return _zentrale_redirect(extension, "err_heimat_belegt")
conn.execute("DELETE FROM assignments WHERE user_azure_id = ?", (user["azure_id"],))
conn.execute("""
@@ -892,12 +891,7 @@ async def zentrale_release(request: Request, extension: str = Form(...)):
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)
return _zentrale_redirect(extension, "zurueck")
finally:
conn.close()