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:
parent
12aa2d7ea1
commit
0d080ff885
19
app.py
19
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
|
||||
|
||||
39
database.py
39
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)"
|
||||
|
||||
66
scheduler.py
66
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",
|
||||
)
|
||||
|
||||
|
||||
@ -17,8 +17,7 @@
|
||||
{% if not authenticated %}
|
||||
<div class="alert alert-warning">
|
||||
<i class="bi bi-exclamation-triangle"></i>
|
||||
Azure ist nicht verbunden. Bitte unter <a href="/settings">Einstellungen</a> konfigurieren und
|
||||
<code>python app.py --auth</code> ausfuehren.
|
||||
Azure ist nicht verbunden. Bitte unter <a href="/settings">Einstellungen</a> konfigurieren und verbinden.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@ -38,7 +37,7 @@
|
||||
<div class="mb-2">
|
||||
<small class="text-muted d-block mb-1">Segment {{ seg.index }}</small>
|
||||
{% 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;">
|
||||
<span class="status-dot me-2" style="background: #ccc;"></span>
|
||||
{{ seg.user }}
|
||||
@ -50,14 +49,14 @@
|
||||
{{ seg.user }}
|
||||
</div>
|
||||
<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>
|
||||
{% elif seg.aida == 'abwesend' %}
|
||||
{% elif seg.attendance == 'abwesend' %}
|
||||
<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>
|
||||
{% else %}
|
||||
<span class="badge bg-light text-dark">{{ seg.aida }}</span>
|
||||
<span class="badge bg-light text-dark">{{ seg.attendance }}</span>
|
||||
{% endif %}
|
||||
|
||||
{% if seg.teams != '-' %}
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
<tr>
|
||||
<th>Zeitpunkt</th>
|
||||
<th>Benutzer</th>
|
||||
<th>AIDA-Status</th>
|
||||
<th>Zeiterfassung</th>
|
||||
<th>Teams-Status</th>
|
||||
<th>WLED-URL</th>
|
||||
<th>Ergebnis</th>
|
||||
@ -27,8 +27,8 @@
|
||||
<td class="text-nowrap">{{ log.timestamp }}</td>
|
||||
<td>{{ log.user_name }}</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 %}">
|
||||
{{ log.aida_status or '-' }}
|
||||
<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.attendance_status or '-' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
|
||||
@ -94,7 +94,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Polling & AIDA -->
|
||||
<!-- Polling & Zeiterfassung -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
@ -129,30 +129,41 @@
|
||||
|
||||
<div class="card">
|
||||
<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 class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">CSV-Pfad</label>
|
||||
<input type="text" name="aida_csv_path" class="form-control font-monospace"
|
||||
value="{{ settings.aida_csv_path }}"
|
||||
placeholder="/mnt/zeiterfassung/Status.csv">
|
||||
<small class="text-muted">Auf Linux z.B. via SMB-Mount</small>
|
||||
<input type="text" name="attendance_csv_path" class="form-control font-monospace"
|
||||
value="{{ settings.attendance_csv_path }}"
|
||||
placeholder="/opt/busylight/status.csv">
|
||||
<small class="text-muted">Pfad zur Anwesenheits-CSV (optional, wenn REST API genutzt wird)</small>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Status: Anwesend</label>
|
||||
<input type="text" name="aida_status_anwesend" class="form-control"
|
||||
value="{{ settings.aida_status_anwesend }}">
|
||||
<label class="form-label">CSV-Code: Anwesend</label>
|
||||
<input type="text" name="attendance_status_anwesend" class="form-control"
|
||||
value="{{ settings.attendance_status_anwesend }}"
|
||||
placeholder="~~1~~">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Status: Abwesend</label>
|
||||
<input type="text" name="aida_status_abwesend" class="form-control"
|
||||
value="{{ settings.aida_status_abwesend }}">
|
||||
<label class="form-label">CSV-Code: Abwesend</label>
|
||||
<input type="text" name="attendance_status_abwesend" class="form-control"
|
||||
value="{{ settings.attendance_status_abwesend }}"
|
||||
placeholder="~~0~~">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Status: Homeoffice</label>
|
||||
<input type="text" name="aida_status_homeoffice" class="form-control"
|
||||
value="{{ settings.aida_status_homeoffice }}">
|
||||
<label class="form-label">CSV-Code: Homeoffice</label>
|
||||
<input type="text" name="attendance_status_homeoffice" class="form-control"
|
||||
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>
|
||||
|
||||
@ -34,7 +34,7 @@
|
||||
<th>Status</th>
|
||||
<th>Name</th>
|
||||
<th>E-Mail</th>
|
||||
<th>Personal-Nr (AIDA)</th>
|
||||
<th>Personal-Nr</th>
|
||||
<th>Durchwahl</th>
|
||||
<th>Zuordnung</th>
|
||||
<th>Aktionen</th>
|
||||
|
||||
127
webhook.py
127
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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user