diff --git a/app.py b/app.py index e08a2d0..0acc598 100644 --- a/app.py +++ b/app.py @@ -192,14 +192,14 @@ async def dashboard(request: Request): for seg_idx in range(dev["segment_count"]): row = conn.execute(""" 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, - 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 FROM assignments a JOIN users u ON u.azure_id = a.user_azure_id LEFT JOIN state s ON s.user_azure_id = u.azure_id - LEFT JOIN color_rules cr_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 WHERE a.wled_device_id = ? AND a.segment = ? """, (dev["id"], seg_idx)).fetchone() @@ -207,10 +207,9 @@ async def dashboard(request: Request): if row: phone = row["phone_active"] if phone: - # Phone active: show phone color pc = conn.execute("SELECT r,g,b FROM color_rules WHERE status='phone_active'").fetchone() r, g, b = (pc["r"], pc["g"], pc["b"]) if pc else (255, 0, 0) - elif row["last_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"] elif row["ar"] is not None: r, g, b = row["ar"], row["ag"], row["ab"] @@ -219,7 +218,7 @@ async def dashboard(request: Request): segments.append({ "index": seg_idx, "user": row["display_name"], - "aida": row["last_aida_status"] or "-", + "attendance": row["last_attendance_status"] or "-", "teams": row["last_teams_status"] or "-", "phone": phone, "r": r, "g": g, "b": b, @@ -227,7 +226,7 @@ async def dashboard(request: Request): else: segments.append({ "index": seg_idx, "user": None, - "aida": "-", "teams": "-", + "attendance": "-", "teams": "-", "r": 128, "g": 128, "b": 128, }) @@ -558,8 +557,8 @@ async def update_settings(request: Request): keys = [ "azure_client_id", "azure_client_secret", "azure_tenant_id", "azure_auth_tenant", "azure_scopes", "poll_interval", - "aida_csv_path", "aida_status_anwesend", "aida_status_abwesend", - "aida_status_homeoffice", + "attendance_csv_path", "attendance_status_anwesend", "attendance_status_abwesend", + "attendance_status_homeoffice", ] for key in keys: value = form.get(key, "") @@ -607,7 +606,7 @@ async def log_page(request: Request): async def api_status(): conn = database.get_db() 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 FROM state s JOIN users u ON u.azure_id = s.user_azure_id diff --git a/database.py b/database.py index 48e8161..797802d 100644 --- a/database.py +++ b/database.py @@ -59,7 +59,7 @@ def init_db(): CREATE TABLE IF NOT EXISTS state ( 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 '', phone_active INTEGER DEFAULT 0, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP @@ -70,7 +70,7 @@ def init_db(): timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, user_azure_id TEXT, user_name TEXT, - aida_status TEXT, + attendance_status TEXT, teams_status TEXT, wled_url TEXT, result TEXT @@ -107,6 +107,27 @@ def init_db(): except Exception: 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 = { "azure_client_id": "", "azure_client_secret": "", @@ -115,10 +136,10 @@ def init_db(): "azure_scopes": "User.Read User.Read.All Presence.Read Presence.Read.All", "azure_auth_record": "", "poll_interval": "15", - "aida_csv_path": "", - "aida_status_anwesend": "~~1~~", - "aida_status_abwesend": "~~0~~", - "aida_status_homeoffice": "~~2~~", + "attendance_csv_path": "", + "attendance_status_anwesend": "~~1~~", + "attendance_status_abwesend": "~~0~~", + "attendance_status_homeoffice": "~~2~~", } for key, value in defaults.items(): conn.execute( @@ -179,14 +200,14 @@ def get_all_settings(): 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 conn = get_db() conn.execute( """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 (?, ?, ?, ?, ?, ?, ?)""", - (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( "DELETE FROM activity_log WHERE id NOT IN (SELECT id FROM activity_log ORDER BY id DESC LIMIT 500)" diff --git a/scheduler.py b/scheduler.py index 7176267..f0939fb 100644 --- a/scheduler.py +++ b/scheduler.py @@ -11,8 +11,8 @@ logger = logging.getLogger(__name__) graph = None -def read_aida_csv(csv_path): - """Read AIDA status CSV. Returns list of lines.""" +def read_attendance_csv(csv_path): + """Read attendance status CSV. Returns list of lines.""" if not csv_path or not os.path.exists(csv_path): return [] try: @@ -20,7 +20,7 @@ def read_aida_csv(csv_path): lines = f.read().splitlines() return lines 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 [] @@ -29,8 +29,8 @@ def _extract_nr(value): return value.strip().strip("~") -def determine_aida_status(lines, personal_nr, settings): - """Determine AIDA status for a user based on their personal number.""" +def determine_attendance_status(lines, personal_nr, settings): + """Determine attendance status for a user based on their personal number.""" if not personal_nr or not lines: return None @@ -45,9 +45,9 @@ def determine_aida_status(lines, personal_nr, settings): line = matching[0] # Check status part (after comma or at end) - status_abwesend = _extract_nr(settings.get("aida_status_abwesend", "~~0~~")) - status_anwesend = _extract_nr(settings.get("aida_status_anwesend", "~~1~~")) - status_homeoffice = _extract_nr(settings.get("aida_status_homeoffice", "~~2~~")) + status_abwesend = _extract_nr(settings.get("attendance_status_abwesend", "~~0~~")) + status_anwesend = _extract_nr(settings.get("attendance_status_anwesend", "~~1~~")) + status_homeoffice = _extract_nr(settings.get("attendance_status_homeoffice", "~~2~~")) # Get the status value (last part after comma, stripped of ~) parts = line.split(",") @@ -90,7 +90,7 @@ def poll_status(): users_with_assignments = conn.execute(""" SELECT u.azure_id, u.display_name, u.personal_nr, 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 FROM users u 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") return - # Read AIDA CSV (kann leer sein) - aida_lines = read_aida_csv(settings.get("aida_csv_path", "")) + # Read attendance CSV (kann leer sein) + attendance_lines = read_attendance_csv(settings.get("attendance_csv_path", "")) for user in users_with_assignments: try: - _process_user(user, aida_lines, settings, conn) + _process_user(user, attendance_lines, settings, conn) except Exception as e: logger.error( "Fehler bei User %s: %s", user["display_name"], e @@ -120,7 +120,7 @@ def poll_status(): 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.""" global graph @@ -129,7 +129,7 @@ def _process_user(user, aida_lines, settings, conn): personal_nr = user["personal_nr"] hostname = user["hostname"] segment = user["segment"] - last_aida = user["last_aida_status"] or "" + last_attendance = user["last_attendance_status"] or "" last_teams = user["last_teams_status"] or "" phone_active = user["phone_active"] @@ -139,29 +139,25 @@ def _process_user(user, aida_lines, settings, conn): if presence and "activity" in presence: teams_status = "teams_" + presence["activity"].lower() - # AIDA-Status ermitteln - aida_status = determine_aida_status(aida_lines, personal_nr, settings) + # Anwesenheitsstatus ermitteln + attendance_status = determine_attendance_status(attendance_lines, personal_nr, settings) # 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 - # --- WLED nur steuern wenn AIDA-Status bekannt und kein aktives Telefonat --- - wled_changed = aida_status is not None and not phone_active and ( - aida_status != last_aida or - (aida_status == "anwesend" and teams_status != last_teams) + # --- WLED nur steuern wenn Anwesenheitsstatus bekannt und kein aktives Telefonat --- + wled_changed = attendance_status is not None and not phone_active and ( + attendance_status != last_attendance or + (attendance_status == "anwesend" and teams_status != last_teams) ) if wled_changed: - # Farbe bestimmen nach Originallogik: - # abwesend -> abwesend-Farbe - # homeoffice -> homeoffice-Farbe - # anwesend -> Teams-Farbe - if aida_status == "abwesend": + if attendance_status == "abwesend": color = get_color_for_status("abwesend", conn) - elif aida_status == "homeoffice": + elif attendance_status == "homeoffice": 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) else: color = get_color_for_status("available", conn) @@ -177,26 +173,26 @@ def _process_user(user, aida_lines, settings, conn): log_activity( azure_id, display_name, - aida_status or "-", teams_status or "-", + attendance_status or "-", teams_status or "-", url, result_text, ) # State immer aktualisieren (fuer Dashboard) 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 (?, ?, ?, ?) 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, - aida_status or last_aida, teams_status, datetime.now(), - aida_status or last_aida, teams_status, datetime.now(), + attendance_status or last_attendance, teams_status, datetime.now(), + attendance_status or last_attendance, teams_status, datetime.now(), )) conn.commit() logger.info( - "%-40s aida=%-12s teams=%-20s wled=%s", - display_name, aida_status or "-", teams_status, + "%-40s zeiterfassung=%-12s teams=%-20s wled=%s", + display_name, attendance_status or "-", teams_status, "aktualisiert" if wled_changed else "nur Dashboard", ) diff --git a/templates/dashboard.html b/templates/dashboard.html index 6c704b3..95c4838 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -17,8 +17,7 @@ {% if not authenticated %}
- Azure ist nicht verbunden. Bitte unter Einstellungen konfigurieren und - python app.py --auth ausfuehren. + Azure ist nicht verbunden. Bitte unter Einstellungen konfigurieren und verbinden.
{% endif %} @@ -38,7 +37,7 @@
Segment {{ seg.index }} {% if seg.user %} - {% if seg.aida == '-' and seg.teams == '-' %} + {% if seg.attendance == '-' and seg.teams == '-' %}
{{ seg.user }} @@ -50,14 +49,14 @@ {{ seg.user }}
- {% if seg.aida == 'anwesend' %} + {% if seg.attendance == 'anwesend' %} Anwesend - {% elif seg.aida == 'abwesend' %} + {% elif seg.attendance == 'abwesend' %} Abwesend - {% elif seg.aida == 'homeoffice' %} + {% elif seg.attendance == 'homeoffice' %} Homeoffice {% else %} - {{ seg.aida }} + {{ seg.attendance }} {% endif %} {% if seg.teams != '-' %} diff --git a/templates/log.html b/templates/log.html index b2c88aa..59af760 100644 --- a/templates/log.html +++ b/templates/log.html @@ -15,7 +15,7 @@ Zeitpunkt Benutzer - AIDA-Status + Zeiterfassung Teams-Status WLED-URL Ergebnis @@ -27,8 +27,8 @@ {{ log.timestamp }} {{ log.user_name }} - - {{ log.aida_status or '-' }} + + {{ log.attendance_status or '-' }} diff --git a/templates/settings.html b/templates/settings.html index fa11ccf..5189ebd 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -94,7 +94,7 @@
- +
@@ -129,30 +129,41 @@
-
AIDA Zeiterfassung
+
Zeiterfassung (CSV)
- - Auf Linux z.B. via SMB-Mount + + Pfad zur Anwesenheits-CSV (optional, wenn REST API genutzt wird)
- - + +
- - + +
- - + + +
+
+ + Alternativ: REST API
+ Status kann auch per API gesetzt werden (Port 8081):
+ POST /api/attendance/{personal_nr}
+ Body: {"status": "anwesend"} +
diff --git a/templates/users.html b/templates/users.html index 0518e70..eddef6b 100644 --- a/templates/users.html +++ b/templates/users.html @@ -34,7 +34,7 @@ Status Name E-Mail - Personal-Nr (AIDA) + Personal-Nr Durchwahl Zuordnung Aktionen diff --git a/webhook.py b/webhook.py index 5f2b87e..fd058f6 100644 --- a/webhook.py +++ b/webhook.py @@ -3,10 +3,9 @@ import logging from datetime import datetime from fastapi import FastAPI -from fastapi.responses import PlainTextResponse +from fastapi.responses import PlainTextResponse, JSONResponse from database import get_db, log_activity -from wled_client import build_wled_url, call_wled logger = logging.getLogger(__name__) @@ -15,15 +14,20 @@ webhook_app = FastAPI(title="Busylight Webhook") @webhook_app.get("/", response_class=PlainTextResponse) 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}/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/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(""" SELECT u.azure_id, u.display_name, 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 JOIN assignments a ON a.user_azure_id = u.azure_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.post("/{extension}/callstart", response_class=PlainTextResponse) async def call_start(extension: str): + from wled_client import build_wled_url, call_wled + conn = get_db() try: 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) return f"Unknown extension: {extension}" - aida = user["last_aida_status"] or "" + attendance = user["last_attendance_status"] or "" # Nur wenn anwesend oder homeoffice - if aida not in ("anwesend", "homeoffice"): - logger.info("Webhook callstart: %s ignoriert (AIDA=%s)", user["display_name"], aida) - return f"Ignored: {user['display_name']} not in office (aida={aida})" + if attendance not in ("anwesend", "homeoffice"): + logger.info("Webhook callstart: %s ignoriert (Zeiterfassung=%s)", user["display_name"], attendance) + return f"Ignored: {user['display_name']} not in office (attendance={attendance})" # WLED auf Telefon-Farbe setzen color = _get_color("phone_active", conn) @@ -84,7 +90,7 @@ async def call_start(extension: str): log_activity( 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) @@ -97,6 +103,8 @@ async def call_start(extension: str): @webhook_app.get("/{extension}/callend", response_class=PlainTextResponse) @webhook_app.post("/{extension}/callend", response_class=PlainTextResponse) async def call_end(extension: str): + from wled_client import build_wled_url, call_wled + conn = get_db() try: 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) return f"Unknown extension: {extension}" - aida = user["last_aida_status"] or "" - if aida not in ("anwesend", "homeoffice"): + attendance = user["last_attendance_status"] or "" + if attendance not in ("anwesend", "homeoffice"): return f"Ignored: {user['display_name']} not in office" # Farbe zuruecksetzen auf Zeiterfassungs-Status - # (Teams-Farbe kommt automatisch beim naechsten Polling) - if aida == "anwesend": + if attendance == "anwesend": color = _get_color("available", conn) - elif aida == "homeoffice": + elif attendance == "homeoffice": color = _get_color("homeoffice", conn) else: color = _get_color("abwesend", conn) @@ -131,11 +138,95 @@ async def call_end(extension: str): log_activity( 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" finally: 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()