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:
@@ -14,6 +14,7 @@ Jedes WLED-Geraet verfuegt ueber zwei Segmente (oben/unten), die jeweils einem B
|
|||||||
- **Telefon-Webhook** fuer Telefonanlage (callstart/callend auf Port 8081)
|
- **Telefon-Webhook** fuer Telefonanlage (callstart/callend auf Port 8081)
|
||||||
- **Modulare Zeiterfassung** per CSV oder REST API - kompatibel mit jeder Zeiterfassung
|
- **Modulare Zeiterfassung** per CSV oder REST API - kompatibel mit jeder Zeiterfassung
|
||||||
- **Webbasierte Azure-Authentifizierung** direkt im Browser (kein Terminal noetig)
|
- **Webbasierte Azure-Authentifizierung** direkt im Browser (kein Terminal noetig)
|
||||||
|
- **Zentrale (Self-Service)** - wechselnde Besetzung des Empfangs-Busylights per Durchwahl, ohne Admin-Passwort
|
||||||
- **Einstellungen** fuer Azure, Polling-Intervall und Zeiterfassung
|
- **Einstellungen** fuer Azure, Polling-Intervall und Zeiterfassung
|
||||||
- **Protokoll** der letzten Statusaenderungen
|
- **Protokoll** der letzten Statusaenderungen
|
||||||
|
|
||||||
@@ -174,6 +175,59 @@ GET/POST http://SERVER:8081/{durchwahl}/callend - Anruf beendet (WLED -> zu
|
|||||||
- Waehrend eines aktiven Telefonats ueberschreibt das Teams-Polling die Farbe nicht
|
- Waehrend eines aktiven Telefonats ueberschreibt das Teams-Polling die Farbe nicht
|
||||||
- Die Telefon-Farbe (`phone_active`) ist unter **Farb-Regeln** konfigurierbar
|
- Die Telefon-Farbe (`phone_active`) ist unter **Farb-Regeln** konfigurierbar
|
||||||
|
|
||||||
|
## Zentrale (Self-Service)
|
||||||
|
|
||||||
|
Ein Busylight im Empfangsbereich wird wechselnd besetzt. Damit die Mitarbeiter das
|
||||||
|
selbst umstellen koennen, gibt es eine eigene Seite, die **ohne Admin-Passwort**
|
||||||
|
erreichbar ist und die Durchwahl als Kennung nutzt.
|
||||||
|
|
||||||
|
### Einrichtung
|
||||||
|
|
||||||
|
1. Unter **Benutzer** die **Durchwahl** pro Mitarbeiter eintragen (dieselbe wie fuer den Telefon-Webhook)
|
||||||
|
2. Unter **Einstellungen > Zentrale (Self-Service)** das Busylight des Empfangs und das Segment auswaehlen
|
||||||
|
3. Die Seite `http://SERVER:8080/zentrale` an die Mitarbeiter geben
|
||||||
|
|
||||||
|
### Bedienung
|
||||||
|
|
||||||
|
```
|
||||||
|
http://SERVER:8080/zentrale - Durchwahl eingeben und Zentrale uebernehmen
|
||||||
|
http://SERVER:8080/zentrale/{durchwahl} - Direktlink pro Person, z.B. als QR-Code
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Direktlink zeigt nur den Stand an und aendert nichts - das Umstellen passiert
|
||||||
|
erst per Knopfdruck.
|
||||||
|
|
||||||
|
### Verhalten
|
||||||
|
|
||||||
|
- **Zentrale uebernehmen**: Der Mitarbeiter wird auf das Segment der Zentrale
|
||||||
|
gesetzt. Das eigene Tuerschild gibt er dabei ab, weil ein Mitarbeiter nur einer
|
||||||
|
Zuordnung gleichzeitig zugewiesen sein kann. Der alte Platz wird gemerkt und das
|
||||||
|
Segment ausgeschaltet.
|
||||||
|
- **Zurueck auf mein Tuerschild**: Der gemerkte Platz wird wiederhergestellt und die
|
||||||
|
Zentrale freigegeben (Farbe `abwesend`, Standard: aus).
|
||||||
|
- **Vorgaenger**: Wer die Zentrale abgibt, bleibt zunaechst ohne Zuordnung - er ist
|
||||||
|
ja nicht da. Sein gemerkter Heimatplatz bleibt erhalten, er kann jederzeit
|
||||||
|
zurueckkehren.
|
||||||
|
- **Belegter Heimatplatz**: Ist das eigene Tuerschild inzwischen jemand anderem
|
||||||
|
zugeordnet, wird die Rueckkehr abgelehnt und nichts geaendert. Dann muss der
|
||||||
|
Administrator die Zuordnung aufloesen.
|
||||||
|
- **Sofortiges Umschalten**: Die Farbe wird direkt ans Geraet geschickt. Das Polling
|
||||||
|
allein wuerde nicht reichen, weil es nur bei einer Statusaenderung sendet und der
|
||||||
|
Status des Mitarbeiters beim Platzwechsel unveraendert ist.
|
||||||
|
- **Protokoll**: Jeder Wechsel landet unter **Protokoll** (`zentrale` beim Uebernehmen,
|
||||||
|
`zentrale_frei` beim Freigeben).
|
||||||
|
- **Vorrang des Administrators**: Wird ein Mitarbeiter unter **Zuordnungen** neu
|
||||||
|
zugeordnet, verfaellt sein gemerkter Heimatplatz - die ausdrueckliche Zuordnung
|
||||||
|
gilt. Wird seine Zuordnung dort nur geloescht, bleibt der gemerkte Platz erhalten,
|
||||||
|
damit er von der Zentrale zurueckkehren kann.
|
||||||
|
|
||||||
|
### Sicherheit
|
||||||
|
|
||||||
|
Die Durchwahl ist eine Kennung, kein Geheimnis. Wer Port 8080 erreicht, kann jeden
|
||||||
|
Kollegen auf die Zentrale setzen. Fuer den internen Gebrauch ist das gewollt - die
|
||||||
|
Aenderung ist sofort sichtbar, im Protokoll nachvollziehbar und mit einem Klick
|
||||||
|
zurueckzunehmen. Die Seite gehoert deshalb nicht ins Internet.
|
||||||
|
|
||||||
## Starten
|
## Starten
|
||||||
|
|
||||||
### Manuell (zum Testen)
|
### Manuell (zum Testen)
|
||||||
@@ -238,7 +292,7 @@ busylight/
|
|||||||
├── docs/ # Dokumentation
|
├── docs/ # Dokumentation
|
||||||
├── static/
|
├── static/
|
||||||
│ └── style.css # CSS Styles
|
│ └── style.css # CSS Styles
|
||||||
└── templates/ # Jinja2 HTML-Templates
|
└── templates/ # Jinja2 HTML-Templates (u.a. zentrale.html)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Ports und Firewall
|
## Ports und Firewall
|
||||||
@@ -274,5 +328,15 @@ busylight/
|
|||||||
- Unter Tuerschilder den Status pruefen ("Alle jetzt pruefen")
|
- Unter Tuerschilder den Status pruefen ("Alle jetzt pruefen")
|
||||||
- Netzwerkverbindung pruefen: `curl http://HOSTNAME/json/info`
|
- Netzwerkverbindung pruefen: `curl http://HOSTNAME/json/info`
|
||||||
|
|
||||||
|
### Zentrale-Seite sagt "noch nicht eingerichtet"
|
||||||
|
|
||||||
|
Unter **Einstellungen > Zentrale (Self-Service)** ist kein Busylight ausgewaehlt.
|
||||||
|
Ohne diese Einstellung weiss die Anwendung nicht, welches Geraet der Empfang ist.
|
||||||
|
|
||||||
|
### Durchwahl wird auf der Zentrale-Seite nicht erkannt
|
||||||
|
|
||||||
|
Die Durchwahl muss unter **Benutzer** exakt so eingetragen sein wie eingegeben, und
|
||||||
|
der Benutzer muss **aktiv** sein. Inaktive Benutzer werden bewusst abgewiesen.
|
||||||
|
|
||||||
### Nach git pull aendert sich nichts
|
### Nach git pull aendert sich nichts
|
||||||
- Service neustarten: `sudo systemctl restart busylight`
|
- Service neustarten: `sudo systemctl restart busylight`
|
||||||
|
|||||||
@@ -469,6 +469,10 @@ async def create_assignment(
|
|||||||
"INSERT OR REPLACE INTO assignments (user_azure_id, wled_device_id, segment) VALUES (?, ?, ?)",
|
"INSERT OR REPLACE INTO assignments (user_azure_id, wled_device_id, segment) VALUES (?, ?, ?)",
|
||||||
(user_azure_id, wled_device_id, segment),
|
(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.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
return RedirectResponse("/assignments", status_code=303)
|
return RedirectResponse("/assignments", status_code=303)
|
||||||
@@ -477,12 +481,258 @@ async def create_assignment(
|
|||||||
@app.post("/api/assignments/{assignment_id}/delete")
|
@app.post("/api/assignments/{assignment_id}/delete")
|
||||||
async def delete_assignment(assignment_id: int):
|
async def delete_assignment(assignment_id: int):
|
||||||
conn = database.get_db()
|
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.execute("DELETE FROM assignments WHERE id=?", (assignment_id,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
return RedirectResponse("/assignments", status_code=303)
|
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 ────────────────────────────────────────────────────────────
|
# ── Color Rules ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@app.get("/colors", response_class=HTMLResponse)
|
@app.get("/colors", response_class=HTMLResponse)
|
||||||
@@ -548,7 +798,12 @@ async def settings_page(request: Request):
|
|||||||
redir = _require_login(request)
|
redir = _require_login(request)
|
||||||
if redir: return redir
|
if redir: return redir
|
||||||
settings = database.get_all_settings()
|
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")
|
@app.post("/api/settings")
|
||||||
@@ -559,6 +814,7 @@ async def update_settings(request: Request):
|
|||||||
"azure_auth_tenant", "azure_scopes", "poll_interval",
|
"azure_auth_tenant", "azure_scopes", "poll_interval",
|
||||||
"attendance_csv_path", "attendance_status_anwesend", "attendance_status_abwesend",
|
"attendance_csv_path", "attendance_status_anwesend", "attendance_status_abwesend",
|
||||||
"attendance_status_homeoffice",
|
"attendance_status_homeoffice",
|
||||||
|
"selfservice_device_id", "selfservice_segment",
|
||||||
]
|
]
|
||||||
for key in keys:
|
for key in keys:
|
||||||
value = form.get(key, "")
|
value = form.get(key, "")
|
||||||
|
|||||||
+10
@@ -49,6 +49,14 @@ def init_db():
|
|||||||
UNIQUE(user_azure_id)
|
UNIQUE(user_azure_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Heimat-Tuerschild: gemerkte Zuordnung, waehrend der Nutzer in der Zentrale sitzt
|
||||||
|
CREATE TABLE IF NOT EXISTS home_assignments (
|
||||||
|
user_azure_id TEXT PRIMARY KEY REFERENCES users(azure_id) ON DELETE CASCADE,
|
||||||
|
wled_device_id INTEGER NOT NULL REFERENCES wled_devices(id) ON DELETE CASCADE,
|
||||||
|
segment INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS color_rules (
|
CREATE TABLE IF NOT EXISTS color_rules (
|
||||||
status TEXT PRIMARY KEY,
|
status TEXT PRIMARY KEY,
|
||||||
r INTEGER NOT NULL DEFAULT 0,
|
r INTEGER NOT NULL DEFAULT 0,
|
||||||
@@ -140,6 +148,8 @@ def init_db():
|
|||||||
"attendance_status_anwesend": "~~1~~",
|
"attendance_status_anwesend": "~~1~~",
|
||||||
"attendance_status_abwesend": "~~0~~",
|
"attendance_status_abwesend": "~~0~~",
|
||||||
"attendance_status_homeoffice": "~~2~~",
|
"attendance_status_homeoffice": "~~2~~",
|
||||||
|
"selfservice_device_id": "",
|
||||||
|
"selfservice_segment": "0",
|
||||||
}
|
}
|
||||||
for key, value in defaults.items():
|
for key, value in defaults.items():
|
||||||
conn.execute(
|
conn.execute(
|
||||||
|
|||||||
@@ -72,6 +72,67 @@ def get_color_for_status(status, conn):
|
|||||||
return {"r": 200, "g": 200, "b": 200, "effect": ""}
|
return {"r": 200, "g": 200, "b": 200, "effect": ""}
|
||||||
|
|
||||||
|
|
||||||
|
def current_color_for_user(azure_id, conn):
|
||||||
|
"""Farbe fuer den zuletzt gespeicherten Status eines Users.
|
||||||
|
|
||||||
|
Prioritaet wie im Polling und im Telefon-Webhook: Telefon > Teams > Zeiterfassung.
|
||||||
|
Wer laut Zeiterfassung nicht anwesend/homeoffice ist, bekommt die abwesend-Farbe.
|
||||||
|
"""
|
||||||
|
row = conn.execute("""
|
||||||
|
SELECT last_attendance_status, last_teams_status,
|
||||||
|
COALESCE(phone_active, 0) as phone_active
|
||||||
|
FROM state WHERE user_azure_id = ?
|
||||||
|
""", (azure_id,)).fetchone()
|
||||||
|
|
||||||
|
attendance = (row["last_attendance_status"] or "") if row else ""
|
||||||
|
teams = (row["last_teams_status"] or "") if row else ""
|
||||||
|
phone_active = row["phone_active"] if row else 0
|
||||||
|
|
||||||
|
if attendance not in ("anwesend", "homeoffice"):
|
||||||
|
return get_color_for_status("abwesend", conn)
|
||||||
|
if phone_active:
|
||||||
|
return get_color_for_status("phone_active", conn)
|
||||||
|
if attendance == "homeoffice":
|
||||||
|
return get_color_for_status("homeoffice", conn)
|
||||||
|
if teams:
|
||||||
|
return get_color_for_status(teams, conn)
|
||||||
|
return get_color_for_status("available", conn)
|
||||||
|
|
||||||
|
|
||||||
|
def push_user_light(azure_id, display_name, hostname, segment, conn, reason="zentrale"):
|
||||||
|
"""Aktuelle Farbe eines Users sofort an WLED senden.
|
||||||
|
|
||||||
|
Wird beim Platzwechsel gebraucht: _process_user schickt nur bei einer
|
||||||
|
Statusaenderung etwas ans Geraet, nach einem Wechsel ist der Status aber
|
||||||
|
unveraendert. Ohne diesen Push wuerde das Segment die Farbe des Vorgaengers
|
||||||
|
behalten, bis sich zufaellig etwas aendert.
|
||||||
|
"""
|
||||||
|
color = current_color_for_user(azure_id, conn)
|
||||||
|
url = build_wled_url(
|
||||||
|
hostname, color["r"], color["g"], color["b"],
|
||||||
|
segment=segment, effect=color.get("effect", ""),
|
||||||
|
)
|
||||||
|
success, result_text = call_wled(url)
|
||||||
|
log_activity(azure_id, display_name, reason, "-", url, result_text)
|
||||||
|
if not success:
|
||||||
|
logger.warning("WLED Fehler beim Platzwechsel fuer %s: %s", display_name, result_text)
|
||||||
|
return success, result_text
|
||||||
|
|
||||||
|
|
||||||
|
def clear_segment(hostname, segment, conn, reason="zentrale_frei"):
|
||||||
|
"""Segment auf die abwesend-Farbe setzen (Standard ist 0,0,0 = aus)."""
|
||||||
|
color = get_color_for_status("abwesend", conn)
|
||||||
|
url = build_wled_url(
|
||||||
|
hostname, color["r"], color["g"], color["b"],
|
||||||
|
segment=segment, effect=color.get("effect", ""),
|
||||||
|
)
|
||||||
|
success, result_text = call_wled(url)
|
||||||
|
log_activity(None, "-", reason, "-", url, result_text)
|
||||||
|
if not success:
|
||||||
|
logger.warning("WLED Fehler beim Freigeben von %s Segment %s: %s", hostname, segment, result_text)
|
||||||
|
return success, result_text
|
||||||
|
|
||||||
|
|
||||||
def poll_status():
|
def poll_status():
|
||||||
"""Main polling function - called periodically by APScheduler."""
|
"""Main polling function - called periodically by APScheduler."""
|
||||||
global graph
|
global graph
|
||||||
|
|||||||
+36
-1
@@ -127,7 +127,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card mb-4">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h6 class="mb-0"><i class="bi bi-file-earmark-spreadsheet"></i> Zeiterfassung (CSV)</h6>
|
<h6 class="mb-0"><i class="bi bi-file-earmark-spreadsheet"></i> Zeiterfassung (CSV)</h6>
|
||||||
</div>
|
</div>
|
||||||
@@ -167,6 +167,41 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h6 class="mb-0"><i class="bi bi-headset"></i> Zentrale (Self-Service)</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Busylight der Zentrale</label>
|
||||||
|
<select name="selfservice_device_id" class="form-select">
|
||||||
|
<option value="">-- nicht eingerichtet --</option>
|
||||||
|
{% for d in devices %}
|
||||||
|
<option value="{{ d.id }}" {% if settings.selfservice_device_id == d.id|string %}selected{% endif %}>
|
||||||
|
{{ d.name or d.hostname }} ({{ d.hostname }})
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<small class="text-muted">Das Busylight im Empfangsbereich, das wechselnd besetzt wird.</small>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Segment</label>
|
||||||
|
<input type="number" name="selfservice_segment" class="form-control"
|
||||||
|
min="0" step="1" value="{{ settings.selfservice_segment or '0' }}">
|
||||||
|
<small class="text-muted">0 = unten, 1 = oben</small>
|
||||||
|
</div>
|
||||||
|
<div class="border rounded p-3 bg-light">
|
||||||
|
<small class="text-muted">
|
||||||
|
<strong>Self-Service-Seite</strong><br>
|
||||||
|
Mitarbeiter melden sich per Durchwahl an, ohne Admin-Passwort:<br>
|
||||||
|
<code>/zentrale</code><br>
|
||||||
|
Direktlink bzw. QR-Code pro Person:<br>
|
||||||
|
<code>/zentrale/{durchwahl}</code>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Zentrale - Busylight</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.2/font/bootstrap-icons.min.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body class="bg-light">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row justify-content-center mt-4 mb-5">
|
||||||
|
<div class="col-md-5">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
<img src="/static/logo.png" alt="cynfo" style="height: 40px;" class="mb-2">
|
||||||
|
<h5 class="mb-0">Zentrale besetzen</h5>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if message %}
|
||||||
|
<div class="alert alert-success py-2">
|
||||||
|
<small><i class="bi bi-check-circle"></i> {{ message }}</small>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert alert-danger py-2">
|
||||||
|
<small><i class="bi bi-exclamation-triangle"></i> {{ error }}</small>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not target %}
|
||||||
|
<div class="alert alert-warning py-2 mb-0">
|
||||||
|
<small>
|
||||||
|
<i class="bi bi-gear"></i>
|
||||||
|
Die Zentrale ist noch nicht eingerichtet. Unter
|
||||||
|
<strong>Einstellungen > Zentrale</strong> muss ein Busylight
|
||||||
|
und ein Segment ausgewaehlt werden.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
<div class="border rounded p-3 mb-4 bg-white">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<small class="text-muted d-block">Busylight der Zentrale</small>
|
||||||
|
<strong>{{ target.name }}</strong>
|
||||||
|
<small class="text-muted">(Segment {{ target.segment }})</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr class="my-2">
|
||||||
|
<small class="text-muted d-block">Aktuell besetzt von</small>
|
||||||
|
{% if occupant %}
|
||||||
|
<strong class="text-success"><i class="bi bi-person-fill"></i> {{ occupant }}</strong>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted"><i class="bi bi-person-dash"></i> niemand</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if user %}
|
||||||
|
<div class="text-center mb-3">
|
||||||
|
<small class="text-muted d-block">Durchwahl {{ user.phone_extension }}</small>
|
||||||
|
<strong>{{ user.display_name }}</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if is_here %}
|
||||||
|
<div class="alert alert-info py-2">
|
||||||
|
<small><i class="bi bi-info-circle"></i> Du sitzt derzeit auf der Zentrale.</small>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="/api/zentrale/uebernehmen" class="mb-2">
|
||||||
|
<input type="hidden" name="extension" value="{{ user.phone_extension }}">
|
||||||
|
<button type="submit" class="btn btn-primary w-100" {% if is_here %}disabled{% endif %}>
|
||||||
|
<i class="bi bi-box-arrow-in-right"></i> Zentrale uebernehmen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="post" action="/api/zentrale/verlassen">
|
||||||
|
<input type="hidden" name="extension" value="{{ user.phone_extension }}">
|
||||||
|
<button type="submit" class="btn btn-outline-secondary w-100">
|
||||||
|
<i class="bi bi-box-arrow-left"></i> Zurueck auf mein Tuerschild
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if home %}
|
||||||
|
<div class="text-center mt-3">
|
||||||
|
<small class="text-muted">
|
||||||
|
Gemerktes Tuerschild: {{ home.name or home.hostname }} (Segment {{ home.segment }})
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="text-center mt-3">
|
||||||
|
<a href="/zentrale" class="text-muted small">Andere Durchwahl</a>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<form method="post" action="/api/zentrale/uebernehmen">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Eigene Telefon-Durchwahl</label>
|
||||||
|
<input type="text" name="extension" class="form-control form-control-lg text-center"
|
||||||
|
inputmode="numeric" autocomplete="off" autofocus required
|
||||||
|
value="{{ extension }}" placeholder="z.B. 123">
|
||||||
|
<small class="text-muted">
|
||||||
|
Damit wird die eigene Person auf das Busylight der Zentrale gesetzt.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary w-100">
|
||||||
|
<i class="bi bi-box-arrow-in-right"></i> Zentrale uebernehmen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="text-center mt-4">
|
||||||
|
<small class="text-secondary" style="font-size:0.65rem">© cynfo GmbH</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user