refactor: AIDA -> modulare Zeiterfassung

- Alle AIDA-Referenzen durch generische Begriffe ersetzt
  (aida -> attendance im Code, "Zeiterfassung" im UI)
- DB-Spalten: last_aida_status -> last_attendance_status
- Settings: aida_csv_path -> attendance_csv_path
- REST API fuer Zeiterfassung auf Port 8081:
  POST /api/attendance/{personal_nr} mit {"status": "anwesend"}
  GET  /api/attendance - alle Status abfragen
- Automatische DB-Migration fuer bestehende Installationen
- CSV bleibt als Option, API als Echtzeit-Alternative

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Mueller 2026-05-03 10:28:45 +02:00
parent 12aa2d7ea1
commit 0d080ff885
8 changed files with 215 additions and 98 deletions

19
app.py
View File

@ -192,14 +192,14 @@ async def dashboard(request: Request):
for seg_idx in range(dev["segment_count"]): for seg_idx in range(dev["segment_count"]):
row = conn.execute(""" row = conn.execute("""
SELECT u.display_name, u.azure_id, SELECT u.display_name, u.azure_id,
s.last_aida_status, s.last_teams_status, s.last_attendance_status, s.last_teams_status,
COALESCE(s.phone_active, 0) as phone_active, COALESCE(s.phone_active, 0) as phone_active,
cr_aida.r as ar, cr_aida.g as ag, cr_aida.b as ab, 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 cr_teams.r as tr, cr_teams.g as tg, cr_teams.b as tb
FROM assignments a FROM assignments a
JOIN users u ON u.azure_id = a.user_azure_id 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 state s ON s.user_azure_id = u.azure_id
LEFT JOIN color_rules cr_aida ON cr_aida.status = s.last_aida_status 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 LEFT JOIN color_rules cr_teams ON cr_teams.status = s.last_teams_status
WHERE a.wled_device_id = ? AND a.segment = ? WHERE a.wled_device_id = ? AND a.segment = ?
""", (dev["id"], seg_idx)).fetchone() """, (dev["id"], seg_idx)).fetchone()
@ -207,10 +207,9 @@ async def dashboard(request: Request):
if row: if row:
phone = row["phone_active"] phone = row["phone_active"]
if phone: if phone:
# Phone active: show phone color
pc = conn.execute("SELECT r,g,b FROM color_rules WHERE status='phone_active'").fetchone() 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) r, g, b = (pc["r"], pc["g"], pc["b"]) if pc else (255, 0, 0)
elif row["last_aida_status"] == "anwesend" and row["tr"] is not None: elif row["last_attendance_status"] == "anwesend" and row["tr"] is not None:
r, g, b = row["tr"], row["tg"], row["tb"] r, g, b = row["tr"], row["tg"], row["tb"]
elif row["ar"] is not None: elif row["ar"] is not None:
r, g, b = row["ar"], row["ag"], row["ab"] r, g, b = row["ar"], row["ag"], row["ab"]
@ -219,7 +218,7 @@ async def dashboard(request: Request):
segments.append({ segments.append({
"index": seg_idx, "index": seg_idx,
"user": row["display_name"], "user": row["display_name"],
"aida": row["last_aida_status"] or "-", "attendance": row["last_attendance_status"] or "-",
"teams": row["last_teams_status"] or "-", "teams": row["last_teams_status"] or "-",
"phone": phone, "phone": phone,
"r": r, "g": g, "b": b, "r": r, "g": g, "b": b,
@ -227,7 +226,7 @@ async def dashboard(request: Request):
else: else:
segments.append({ segments.append({
"index": seg_idx, "user": None, "index": seg_idx, "user": None,
"aida": "-", "teams": "-", "attendance": "-", "teams": "-",
"r": 128, "g": 128, "b": 128, "r": 128, "g": 128, "b": 128,
}) })
@ -558,8 +557,8 @@ async def update_settings(request: Request):
keys = [ keys = [
"azure_client_id", "azure_client_secret", "azure_tenant_id", "azure_client_id", "azure_client_secret", "azure_tenant_id",
"azure_auth_tenant", "azure_scopes", "poll_interval", "azure_auth_tenant", "azure_scopes", "poll_interval",
"aida_csv_path", "aida_status_anwesend", "aida_status_abwesend", "attendance_csv_path", "attendance_status_anwesend", "attendance_status_abwesend",
"aida_status_homeoffice", "attendance_status_homeoffice",
] ]
for key in keys: for key in keys:
value = form.get(key, "") value = form.get(key, "")
@ -607,7 +606,7 @@ async def log_page(request: Request):
async def api_status(): async def api_status():
conn = database.get_db() conn = database.get_db()
states = conn.execute(""" states = conn.execute("""
SELECT u.display_name, u.azure_id, s.last_aida_status, s.last_teams_status, SELECT u.display_name, u.azure_id, s.last_attendance_status, s.last_teams_status,
s.updated_at, d.hostname, a.segment s.updated_at, d.hostname, a.segment
FROM state s FROM state s
JOIN users u ON u.azure_id = s.user_azure_id JOIN users u ON u.azure_id = s.user_azure_id

View File

@ -59,7 +59,7 @@ def init_db():
CREATE TABLE IF NOT EXISTS state ( CREATE TABLE IF NOT EXISTS state (
user_azure_id TEXT PRIMARY KEY REFERENCES users(azure_id) ON DELETE CASCADE, user_azure_id TEXT PRIMARY KEY REFERENCES users(azure_id) ON DELETE CASCADE,
last_aida_status TEXT DEFAULT '', last_attendance_status TEXT DEFAULT '',
last_teams_status TEXT DEFAULT '', last_teams_status TEXT DEFAULT '',
phone_active INTEGER DEFAULT 0, phone_active INTEGER DEFAULT 0,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
@ -70,7 +70,7 @@ def init_db():
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
user_azure_id TEXT, user_azure_id TEXT,
user_name TEXT, user_name TEXT,
aida_status TEXT, attendance_status TEXT,
teams_status TEXT, teams_status TEXT,
wled_url TEXT, wled_url TEXT,
result TEXT result TEXT
@ -107,6 +107,27 @@ def init_db():
except Exception: except Exception:
pass pass
# Migrate: rename aida columns to attendance
try:
conn.execute("ALTER TABLE state RENAME COLUMN last_aida_status TO last_attendance_status")
except Exception:
pass
try:
conn.execute("ALTER TABLE activity_log RENAME COLUMN aida_status TO attendance_status")
except Exception:
pass
# Migrate: rename aida settings keys
for old_key, new_key in [
("aida_csv_path", "attendance_csv_path"),
("aida_status_anwesend", "attendance_status_anwesend"),
("aida_status_abwesend", "attendance_status_abwesend"),
("aida_status_homeoffice", "attendance_status_homeoffice"),
]:
old_val = conn.execute("SELECT value FROM settings WHERE key=?", (old_key,)).fetchone()
if old_val:
conn.execute("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", (new_key, old_val["value"]))
conn.execute("DELETE FROM settings WHERE key=?", (old_key,))
defaults = { defaults = {
"azure_client_id": "", "azure_client_id": "",
"azure_client_secret": "", "azure_client_secret": "",
@ -115,10 +136,10 @@ def init_db():
"azure_scopes": "User.Read User.Read.All Presence.Read Presence.Read.All", "azure_scopes": "User.Read User.Read.All Presence.Read Presence.Read.All",
"azure_auth_record": "", "azure_auth_record": "",
"poll_interval": "15", "poll_interval": "15",
"aida_csv_path": "", "attendance_csv_path": "",
"aida_status_anwesend": "~~1~~", "attendance_status_anwesend": "~~1~~",
"aida_status_abwesend": "~~0~~", "attendance_status_abwesend": "~~0~~",
"aida_status_homeoffice": "~~2~~", "attendance_status_homeoffice": "~~2~~",
} }
for key, value in defaults.items(): for key, value in defaults.items():
conn.execute( conn.execute(
@ -179,14 +200,14 @@ def get_all_settings():
return {row["key"]: row["value"] for row in rows} return {row["key"]: row["value"] for row in rows}
def log_activity(user_azure_id, user_name, aida_status, teams_status, wled_url, result): def log_activity(user_azure_id, user_name, attendance_status, teams_status, wled_url, result):
from datetime import datetime from datetime import datetime
conn = get_db() conn = get_db()
conn.execute( conn.execute(
"""INSERT INTO activity_log """INSERT INTO activity_log
(timestamp, user_azure_id, user_name, aida_status, teams_status, wled_url, result) (timestamp, user_azure_id, user_name, attendance_status, teams_status, wled_url, result)
VALUES (?, ?, ?, ?, ?, ?, ?)""", VALUES (?, ?, ?, ?, ?, ?, ?)""",
(datetime.now().strftime("%Y-%m-%d %H:%M:%S"), user_azure_id, user_name, aida_status, teams_status, wled_url, result), (datetime.now().strftime("%Y-%m-%d %H:%M:%S"), user_azure_id, user_name, attendance_status, teams_status, wled_url, result),
) )
conn.execute( conn.execute(
"DELETE FROM activity_log WHERE id NOT IN (SELECT id FROM activity_log ORDER BY id DESC LIMIT 500)" "DELETE FROM activity_log WHERE id NOT IN (SELECT id FROM activity_log ORDER BY id DESC LIMIT 500)"

View File

@ -11,8 +11,8 @@ logger = logging.getLogger(__name__)
graph = None graph = None
def read_aida_csv(csv_path): def read_attendance_csv(csv_path):
"""Read AIDA status CSV. Returns list of lines.""" """Read attendance status CSV. Returns list of lines."""
if not csv_path or not os.path.exists(csv_path): if not csv_path or not os.path.exists(csv_path):
return [] return []
try: try:
@ -20,7 +20,7 @@ def read_aida_csv(csv_path):
lines = f.read().splitlines() lines = f.read().splitlines()
return lines return lines
except Exception as e: except Exception as e:
logger.error("Fehler beim Lesen der AIDA CSV: %s", e) logger.error("Fehler beim Lesen der Zeiterfassungs-CSV: %s", e)
return [] return []
@ -29,8 +29,8 @@ def _extract_nr(value):
return value.strip().strip("~") return value.strip().strip("~")
def determine_aida_status(lines, personal_nr, settings): def determine_attendance_status(lines, personal_nr, settings):
"""Determine AIDA status for a user based on their personal number.""" """Determine attendance status for a user based on their personal number."""
if not personal_nr or not lines: if not personal_nr or not lines:
return None return None
@ -45,9 +45,9 @@ def determine_aida_status(lines, personal_nr, settings):
line = matching[0] line = matching[0]
# Check status part (after comma or at end) # Check status part (after comma or at end)
status_abwesend = _extract_nr(settings.get("aida_status_abwesend", "~~0~~")) status_abwesend = _extract_nr(settings.get("attendance_status_abwesend", "~~0~~"))
status_anwesend = _extract_nr(settings.get("aida_status_anwesend", "~~1~~")) status_anwesend = _extract_nr(settings.get("attendance_status_anwesend", "~~1~~"))
status_homeoffice = _extract_nr(settings.get("aida_status_homeoffice", "~~2~~")) status_homeoffice = _extract_nr(settings.get("attendance_status_homeoffice", "~~2~~"))
# Get the status value (last part after comma, stripped of ~) # Get the status value (last part after comma, stripped of ~)
parts = line.split(",") parts = line.split(",")
@ -90,7 +90,7 @@ def poll_status():
users_with_assignments = conn.execute(""" users_with_assignments = conn.execute("""
SELECT u.azure_id, u.display_name, u.personal_nr, SELECT u.azure_id, u.display_name, u.personal_nr,
a.segment, d.hostname, a.segment, d.hostname,
s.last_aida_status, s.last_teams_status, s.last_attendance_status, s.last_teams_status,
COALESCE(s.phone_active, 0) as phone_active COALESCE(s.phone_active, 0) as phone_active
FROM users u FROM users u
JOIN assignments a ON a.user_azure_id = u.azure_id JOIN assignments a ON a.user_azure_id = u.azure_id
@ -103,12 +103,12 @@ def poll_status():
logger.debug("Keine aktiven User mit Zuordnungen gefunden") logger.debug("Keine aktiven User mit Zuordnungen gefunden")
return return
# Read AIDA CSV (kann leer sein) # Read attendance CSV (kann leer sein)
aida_lines = read_aida_csv(settings.get("aida_csv_path", "")) attendance_lines = read_attendance_csv(settings.get("attendance_csv_path", ""))
for user in users_with_assignments: for user in users_with_assignments:
try: try:
_process_user(user, aida_lines, settings, conn) _process_user(user, attendance_lines, settings, conn)
except Exception as e: except Exception as e:
logger.error( logger.error(
"Fehler bei User %s: %s", user["display_name"], e "Fehler bei User %s: %s", user["display_name"], e
@ -120,7 +120,7 @@ def poll_status():
conn.close() conn.close()
def _process_user(user, aida_lines, settings, conn): def _process_user(user, attendance_lines, settings, conn):
"""Process a single user: check status and update WLED if needed.""" """Process a single user: check status and update WLED if needed."""
global graph global graph
@ -129,7 +129,7 @@ def _process_user(user, aida_lines, settings, conn):
personal_nr = user["personal_nr"] personal_nr = user["personal_nr"]
hostname = user["hostname"] hostname = user["hostname"]
segment = user["segment"] segment = user["segment"]
last_aida = user["last_aida_status"] or "" last_attendance = user["last_attendance_status"] or ""
last_teams = user["last_teams_status"] or "" last_teams = user["last_teams_status"] or ""
phone_active = user["phone_active"] phone_active = user["phone_active"]
@ -139,29 +139,25 @@ def _process_user(user, aida_lines, settings, conn):
if presence and "activity" in presence: if presence and "activity" in presence:
teams_status = "teams_" + presence["activity"].lower() teams_status = "teams_" + presence["activity"].lower()
# AIDA-Status ermitteln # Anwesenheitsstatus ermitteln
aida_status = determine_aida_status(aida_lines, personal_nr, settings) attendance_status = determine_attendance_status(attendance_lines, personal_nr, settings)
# Check if anything changed # Check if anything changed
if aida_status == last_aida and teams_status == last_teams: if attendance_status == last_attendance and teams_status == last_teams:
return # No change return # No change
# --- WLED nur steuern wenn AIDA-Status bekannt und kein aktives Telefonat --- # --- WLED nur steuern wenn Anwesenheitsstatus bekannt und kein aktives Telefonat ---
wled_changed = aida_status is not None and not phone_active and ( wled_changed = attendance_status is not None and not phone_active and (
aida_status != last_aida or attendance_status != last_attendance or
(aida_status == "anwesend" and teams_status != last_teams) (attendance_status == "anwesend" and teams_status != last_teams)
) )
if wled_changed: if wled_changed:
# Farbe bestimmen nach Originallogik: if attendance_status == "abwesend":
# abwesend -> abwesend-Farbe
# homeoffice -> homeoffice-Farbe
# anwesend -> Teams-Farbe
if aida_status == "abwesend":
color = get_color_for_status("abwesend", conn) color = get_color_for_status("abwesend", conn)
elif aida_status == "homeoffice": elif attendance_status == "homeoffice":
color = get_color_for_status("homeoffice", conn) color = get_color_for_status("homeoffice", conn)
elif aida_status == "anwesend" and teams_status: elif attendance_status == "anwesend" and teams_status:
color = get_color_for_status(teams_status, conn) color = get_color_for_status(teams_status, conn)
else: else:
color = get_color_for_status("available", conn) color = get_color_for_status("available", conn)
@ -177,26 +173,26 @@ def _process_user(user, aida_lines, settings, conn):
log_activity( log_activity(
azure_id, display_name, azure_id, display_name,
aida_status or "-", teams_status or "-", attendance_status or "-", teams_status or "-",
url, result_text, url, result_text,
) )
# State immer aktualisieren (fuer Dashboard) # State immer aktualisieren (fuer Dashboard)
conn.execute(""" conn.execute("""
INSERT INTO state (user_azure_id, last_aida_status, last_teams_status, updated_at) INSERT INTO state (user_azure_id, last_attendance_status, last_teams_status, updated_at)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
ON CONFLICT(user_azure_id) ON CONFLICT(user_azure_id)
DO UPDATE SET last_aida_status=?, last_teams_status=?, updated_at=? DO UPDATE SET last_attendance_status=?, last_teams_status=?, updated_at=?
""", ( """, (
azure_id, azure_id,
aida_status or last_aida, teams_status, datetime.now(), attendance_status or last_attendance, teams_status, datetime.now(),
aida_status or last_aida, teams_status, datetime.now(), attendance_status or last_attendance, teams_status, datetime.now(),
)) ))
conn.commit() conn.commit()
logger.info( logger.info(
"%-40s aida=%-12s teams=%-20s wled=%s", "%-40s zeiterfassung=%-12s teams=%-20s wled=%s",
display_name, aida_status or "-", teams_status, display_name, attendance_status or "-", teams_status,
"aktualisiert" if wled_changed else "nur Dashboard", "aktualisiert" if wled_changed else "nur Dashboard",
) )

View File

@ -17,8 +17,7 @@
{% if not authenticated %} {% if not authenticated %}
<div class="alert alert-warning"> <div class="alert alert-warning">
<i class="bi bi-exclamation-triangle"></i> <i class="bi bi-exclamation-triangle"></i>
Azure ist nicht verbunden. Bitte unter <a href="/settings">Einstellungen</a> konfigurieren und Azure ist nicht verbunden. Bitte unter <a href="/settings">Einstellungen</a> konfigurieren und verbinden.
<code>python app.py --auth</code> ausfuehren.
</div> </div>
{% endif %} {% endif %}
@ -38,7 +37,7 @@
<div class="mb-2"> <div class="mb-2">
<small class="text-muted d-block mb-1">Segment {{ seg.index }}</small> <small class="text-muted d-block mb-1">Segment {{ seg.index }}</small>
{% if seg.user %} {% if seg.user %}
{% if seg.aida == '-' and seg.teams == '-' %} {% if seg.attendance == '-' and seg.teams == '-' %}
<div class="segment-bar" style="background: #f0f0f0; border-color: #ccc;"> <div class="segment-bar" style="background: #f0f0f0; border-color: #ccc;">
<span class="status-dot me-2" style="background: #ccc;"></span> <span class="status-dot me-2" style="background: #ccc;"></span>
{{ seg.user }} {{ seg.user }}
@ -50,14 +49,14 @@
{{ seg.user }} {{ seg.user }}
</div> </div>
<div class="d-flex gap-2 mt-1"> <div class="d-flex gap-2 mt-1">
{% if seg.aida == 'anwesend' %} {% if seg.attendance == 'anwesend' %}
<span class="badge bg-success"><i class="bi bi-building"></i> Anwesend</span> <span class="badge bg-success"><i class="bi bi-building"></i> Anwesend</span>
{% elif seg.aida == 'abwesend' %} {% elif seg.attendance == 'abwesend' %}
<span class="badge bg-secondary"><i class="bi bi-door-open"></i> Abwesend</span> <span class="badge bg-secondary"><i class="bi bi-door-open"></i> Abwesend</span>
{% elif seg.aida == 'homeoffice' %} {% elif seg.attendance == 'homeoffice' %}
<span class="badge bg-warning text-dark"><i class="bi bi-house"></i> Homeoffice</span> <span class="badge bg-warning text-dark"><i class="bi bi-house"></i> Homeoffice</span>
{% else %} {% else %}
<span class="badge bg-light text-dark">{{ seg.aida }}</span> <span class="badge bg-light text-dark">{{ seg.attendance }}</span>
{% endif %} {% endif %}
{% if seg.teams != '-' %} {% if seg.teams != '-' %}

View File

@ -15,7 +15,7 @@
<tr> <tr>
<th>Zeitpunkt</th> <th>Zeitpunkt</th>
<th>Benutzer</th> <th>Benutzer</th>
<th>AIDA-Status</th> <th>Zeiterfassung</th>
<th>Teams-Status</th> <th>Teams-Status</th>
<th>WLED-URL</th> <th>WLED-URL</th>
<th>Ergebnis</th> <th>Ergebnis</th>
@ -27,8 +27,8 @@
<td class="text-nowrap">{{ log.timestamp }}</td> <td class="text-nowrap">{{ log.timestamp }}</td>
<td>{{ log.user_name }}</td> <td>{{ log.user_name }}</td>
<td> <td>
<span class="badge {% if log.aida_status == 'anwesend' %}bg-success{% elif log.aida_status == 'abwesend' %}bg-secondary{% elif log.aida_status == 'homeoffice' %}bg-warning text-dark{% else %}bg-light text-dark{% endif %}"> <span class="badge {% if log.attendance_status == 'anwesend' %}bg-success{% elif log.attendance_status == 'abwesend' %}bg-secondary{% elif log.attendance_status == 'homeoffice' %}bg-warning text-dark{% else %}bg-light text-dark{% endif %}">
{{ log.aida_status or '-' }} {{ log.attendance_status or '-' }}
</span> </span>
</td> </td>
<td> <td>

View File

@ -94,7 +94,7 @@
</div> </div>
</div> </div>
<!-- Polling & AIDA --> <!-- Polling & Zeiterfassung -->
<div class="col-lg-6"> <div class="col-lg-6">
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header"> <div class="card-header">
@ -129,30 +129,41 @@
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h6 class="mb-0"><i class="bi bi-file-earmark-spreadsheet"></i> AIDA Zeiterfassung</h6> <h6 class="mb-0"><i class="bi bi-file-earmark-spreadsheet"></i> Zeiterfassung (CSV)</h6>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="mb-3"> <div class="mb-3">
<label class="form-label">CSV-Pfad</label> <label class="form-label">CSV-Pfad</label>
<input type="text" name="aida_csv_path" class="form-control font-monospace" <input type="text" name="attendance_csv_path" class="form-control font-monospace"
value="{{ settings.aida_csv_path }}" value="{{ settings.attendance_csv_path }}"
placeholder="/mnt/zeiterfassung/Status.csv"> placeholder="/opt/busylight/status.csv">
<small class="text-muted">Auf Linux z.B. via SMB-Mount</small> <small class="text-muted">Pfad zur Anwesenheits-CSV (optional, wenn REST API genutzt wird)</small>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Status: Anwesend</label> <label class="form-label">CSV-Code: Anwesend</label>
<input type="text" name="aida_status_anwesend" class="form-control" <input type="text" name="attendance_status_anwesend" class="form-control"
value="{{ settings.aida_status_anwesend }}"> value="{{ settings.attendance_status_anwesend }}"
placeholder="~~1~~">
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Status: Abwesend</label> <label class="form-label">CSV-Code: Abwesend</label>
<input type="text" name="aida_status_abwesend" class="form-control" <input type="text" name="attendance_status_abwesend" class="form-control"
value="{{ settings.aida_status_abwesend }}"> value="{{ settings.attendance_status_abwesend }}"
placeholder="~~0~~">
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Status: Homeoffice</label> <label class="form-label">CSV-Code: Homeoffice</label>
<input type="text" name="aida_status_homeoffice" class="form-control" <input type="text" name="attendance_status_homeoffice" class="form-control"
value="{{ settings.aida_status_homeoffice }}"> value="{{ settings.attendance_status_homeoffice }}"
placeholder="~~2~~">
</div>
<div class="border rounded p-3 bg-light">
<small class="text-muted">
<strong>Alternativ: REST API</strong><br>
Status kann auch per API gesetzt werden (Port 8081):<br>
<code>POST /api/attendance/{personal_nr}</code><br>
Body: <code>{"status": "anwesend"}</code>
</small>
</div> </div>
</div> </div>
</div> </div>

View File

@ -34,7 +34,7 @@
<th>Status</th> <th>Status</th>
<th>Name</th> <th>Name</th>
<th>E-Mail</th> <th>E-Mail</th>
<th>Personal-Nr (AIDA)</th> <th>Personal-Nr</th>
<th>Durchwahl</th> <th>Durchwahl</th>
<th>Zuordnung</th> <th>Zuordnung</th>
<th>Aktionen</th> <th>Aktionen</th>

View File

@ -3,10 +3,9 @@
import logging import logging
from datetime import datetime from datetime import datetime
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.responses import PlainTextResponse from fastapi.responses import PlainTextResponse, JSONResponse
from database import get_db, log_activity from database import get_db, log_activity
from wled_client import build_wled_url, call_wled
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -15,15 +14,20 @@ webhook_app = FastAPI(title="Busylight Webhook")
@webhook_app.get("/", response_class=PlainTextResponse) @webhook_app.get("/", response_class=PlainTextResponse)
async def webhook_info(): async def webhook_info():
return """Busylight Webhook API (Telefonanlage) return """Busylight Webhook API
Endpoints: Telefon-Endpoints:
GET/POST /{durchwahl}/callstart - Anruf gestartet (WLED -> Rot) GET/POST /{durchwahl}/callstart - Anruf gestartet (WLED -> Rot)
GET/POST /{durchwahl}/callend - Anruf beendet (WLED -> zurueck auf Zeiterfassungs-Status) GET/POST /{durchwahl}/callend - Anruf beendet (WLED -> zurueck auf Zeiterfassungs-Status)
Beispiel: Zeiterfassungs-Endpoint:
POST /api/attendance/{personal_nr} - Anwesenheitsstatus setzen
Body: {"status": "anwesend|abwesend|homeoffice"}
Beispiele:
curl http://localhost:8081/123/callstart curl http://localhost:8081/123/callstart
curl http://localhost:8081/123/callend curl http://localhost:8081/123/callend
curl -X POST -H "Content-Type: application/json" -d '{"status":"anwesend"}' http://localhost:8081/api/attendance/00341
""" """
@ -33,7 +37,7 @@ def _get_user_by_extension(extension, conn):
return conn.execute(""" return conn.execute("""
SELECT u.azure_id, u.display_name, SELECT u.azure_id, u.display_name,
a.segment, d.hostname, a.segment, d.hostname,
s.last_aida_status, s.last_teams_status, s.phone_active s.last_attendance_status, s.last_teams_status, s.phone_active
FROM users u FROM users u
JOIN assignments a ON a.user_azure_id = u.azure_id JOIN assignments a ON a.user_azure_id = u.azure_id
JOIN wled_devices d ON d.id = a.wled_device_id JOIN wled_devices d ON d.id = a.wled_device_id
@ -55,6 +59,8 @@ def _get_color(status, conn):
@webhook_app.get("/{extension}/callstart", response_class=PlainTextResponse) @webhook_app.get("/{extension}/callstart", response_class=PlainTextResponse)
@webhook_app.post("/{extension}/callstart", response_class=PlainTextResponse) @webhook_app.post("/{extension}/callstart", response_class=PlainTextResponse)
async def call_start(extension: str): async def call_start(extension: str):
from wled_client import build_wled_url, call_wled
conn = get_db() conn = get_db()
try: try:
user = _get_user_by_extension(extension, conn) user = _get_user_by_extension(extension, conn)
@ -62,11 +68,11 @@ async def call_start(extension: str):
logger.warning("Webhook callstart: Durchwahl %s nicht gefunden", extension) logger.warning("Webhook callstart: Durchwahl %s nicht gefunden", extension)
return f"Unknown extension: {extension}" return f"Unknown extension: {extension}"
aida = user["last_aida_status"] or "" attendance = user["last_attendance_status"] or ""
# Nur wenn anwesend oder homeoffice # Nur wenn anwesend oder homeoffice
if aida not in ("anwesend", "homeoffice"): if attendance not in ("anwesend", "homeoffice"):
logger.info("Webhook callstart: %s ignoriert (AIDA=%s)", user["display_name"], aida) logger.info("Webhook callstart: %s ignoriert (Zeiterfassung=%s)", user["display_name"], attendance)
return f"Ignored: {user['display_name']} not in office (aida={aida})" return f"Ignored: {user['display_name']} not in office (attendance={attendance})"
# WLED auf Telefon-Farbe setzen # WLED auf Telefon-Farbe setzen
color = _get_color("phone_active", conn) color = _get_color("phone_active", conn)
@ -84,7 +90,7 @@ async def call_start(extension: str):
log_activity( log_activity(
user["azure_id"], user["display_name"], user["azure_id"], user["display_name"],
aida, "phone_active", url, result_text, attendance, "phone_active", url, result_text,
) )
logger.info("Webhook callstart: %s -> %s", user["display_name"], result_text) logger.info("Webhook callstart: %s -> %s", user["display_name"], result_text)
@ -97,6 +103,8 @@ async def call_start(extension: str):
@webhook_app.get("/{extension}/callend", response_class=PlainTextResponse) @webhook_app.get("/{extension}/callend", response_class=PlainTextResponse)
@webhook_app.post("/{extension}/callend", response_class=PlainTextResponse) @webhook_app.post("/{extension}/callend", response_class=PlainTextResponse)
async def call_end(extension: str): async def call_end(extension: str):
from wled_client import build_wled_url, call_wled
conn = get_db() conn = get_db()
try: try:
user = _get_user_by_extension(extension, conn) user = _get_user_by_extension(extension, conn)
@ -104,15 +112,14 @@ async def call_end(extension: str):
logger.warning("Webhook callend: Durchwahl %s nicht gefunden", extension) logger.warning("Webhook callend: Durchwahl %s nicht gefunden", extension)
return f"Unknown extension: {extension}" return f"Unknown extension: {extension}"
aida = user["last_aida_status"] or "" attendance = user["last_attendance_status"] or ""
if aida not in ("anwesend", "homeoffice"): if attendance not in ("anwesend", "homeoffice"):
return f"Ignored: {user['display_name']} not in office" return f"Ignored: {user['display_name']} not in office"
# Farbe zuruecksetzen auf Zeiterfassungs-Status # Farbe zuruecksetzen auf Zeiterfassungs-Status
# (Teams-Farbe kommt automatisch beim naechsten Polling) if attendance == "anwesend":
if aida == "anwesend":
color = _get_color("available", conn) color = _get_color("available", conn)
elif aida == "homeoffice": elif attendance == "homeoffice":
color = _get_color("homeoffice", conn) color = _get_color("homeoffice", conn)
else: else:
color = _get_color("abwesend", conn) color = _get_color("abwesend", conn)
@ -131,11 +138,95 @@ async def call_end(extension: str):
log_activity( log_activity(
user["azure_id"], user["display_name"], user["azure_id"], user["display_name"],
aida, "phone_end", url, result_text, attendance, "phone_end", url, result_text,
) )
logger.info("Webhook callend: %s -> %s (%s)", user["display_name"], result_text, aida) logger.info("Webhook callend: %s -> %s (%s)", user["display_name"], result_text, attendance)
return f"OK: {user['display_name']} call ended" return f"OK: {user['display_name']} call ended"
finally: finally:
conn.close() conn.close()
# ── REST API fuer Zeiterfassung ───────────────────────────────────────────
@webhook_app.post("/api/attendance/{personal_nr}")
async def set_attendance(personal_nr: str, request_body: dict = None):
"""Set attendance status for a user via REST API.
Body: {"status": "anwesend|abwesend|homeoffice"}
This allows external time tracking systems to push status updates
directly instead of using a CSV file.
"""
if not request_body or "status" not in request_body:
return JSONResponse(
{"error": "Body muss {'status': 'anwesend|abwesend|homeoffice'} enthalten"},
status_code=400,
)
status = request_body["status"]
if status not in ("anwesend", "abwesend", "homeoffice"):
return JSONResponse(
{"error": f"Ungueltiger Status: {status}. Erlaubt: anwesend, abwesend, homeoffice"},
status_code=400,
)
conn = get_db()
try:
# Find user by personal_nr (strip ~~ if present)
nr_clean = personal_nr.strip().strip("~")
user = conn.execute("""
SELECT u.azure_id, u.display_name, u.personal_nr,
a.segment, d.hostname
FROM users u
JOIN assignments a ON a.user_azure_id = u.azure_id
JOIN wled_devices d ON d.id = a.wled_device_id
WHERE u.active = 1 AND REPLACE(REPLACE(u.personal_nr, '~', ''), ' ', '') = ?
""", (nr_clean,)).fetchone()
if not user:
return JSONResponse(
{"error": f"Kein aktiver User mit Personalnummer {personal_nr} gefunden"},
status_code=404,
)
# Update state
conn.execute("""
INSERT INTO state (user_azure_id, last_attendance_status, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(user_azure_id)
DO UPDATE SET last_attendance_status=?, updated_at=?
""", (
user["azure_id"], status, datetime.now(),
status, datetime.now(),
))
conn.commit()
logger.info("API Zeiterfassung: %s -> %s", user["display_name"], status)
return JSONResponse({
"ok": True,
"user": user["display_name"],
"status": status,
})
finally:
conn.close()
@webhook_app.get("/api/attendance", response_class=JSONResponse)
async def get_all_attendance():
"""Get current attendance status of all active users."""
conn = get_db()
try:
rows = conn.execute("""
SELECT u.display_name, u.personal_nr,
COALESCE(s.last_attendance_status, '') as status
FROM users u
LEFT JOIN state s ON s.user_azure_id = u.azure_id
WHERE u.active = 1
ORDER BY u.display_name
""").fetchall()
return [{"user": r["display_name"], "personal_nr": r["personal_nr"], "status": r["status"] or "unbekannt"} for r in rows]
finally:
conn.close()