busylight_modular/scheduler.py
Christian Mueller 0d080ff885 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>
2026-05-03 10:28:45 +02:00

229 lines
7.8 KiB
Python

import logging
import os
from datetime import datetime
from database import get_db, get_setting, log_activity
from wled_client import build_wled_url, call_wled, get_wled_info
logger = logging.getLogger(__name__)
# Global reference to graph client (set by app.py on startup)
graph = None
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:
with open(csv_path, "r", encoding="utf-8", errors="replace") as f:
lines = f.read().splitlines()
return lines
except Exception as e:
logger.error("Fehler beim Lesen der Zeiterfassungs-CSV: %s", e)
return []
def _extract_nr(value):
"""Extract bare number from ~~00341~~ -> 00341."""
return value.strip().strip("~")
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
nr = _extract_nr(personal_nr)
if not nr:
return None
# Find line containing this personal number
matching = [l for l in lines if nr in l]
if not matching:
return None
line = matching[0]
# Check status part (after comma or at end)
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(",")
status_val = _extract_nr(parts[-1]) if len(parts) > 1 else ""
if status_val == status_abwesend:
return "abwesend"
if status_val == status_anwesend:
return "anwesend"
if status_val == status_homeoffice:
return "homeoffice"
return None
def get_color_for_status(status, conn):
"""Get RGB color for a given status from color_rules."""
row = conn.execute(
"SELECT r, g, b, effect FROM color_rules WHERE status = ?", (status,)
).fetchone()
if row:
return dict(row)
return {"r": 200, "g": 200, "b": 200, "effect": ""}
def poll_status():
"""Main polling function - called periodically by APScheduler."""
global graph
if graph is None or not graph.is_authenticated():
logger.warning("Graph-Client nicht authentifiziert, ueberspringe Polling")
return
conn = get_db()
try:
settings = {}
for row in conn.execute("SELECT key, value FROM settings").fetchall():
settings[row["key"]] = row["value"]
# Get all active users with assignments
users_with_assignments = conn.execute("""
SELECT u.azure_id, u.display_name, u.personal_nr,
a.segment, d.hostname,
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
JOIN wled_devices d ON d.id = a.wled_device_id
LEFT JOIN state s ON s.user_azure_id = u.azure_id
WHERE u.active = 1
""").fetchall()
if not users_with_assignments:
logger.debug("Keine aktiven User mit Zuordnungen gefunden")
return
# 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, attendance_lines, settings, conn)
except Exception as e:
logger.error(
"Fehler bei User %s: %s", user["display_name"], e
)
except Exception as e:
logger.error("Polling Fehler: %s", e)
finally:
conn.close()
def _process_user(user, attendance_lines, settings, conn):
"""Process a single user: check status and update WLED if needed."""
global graph
azure_id = user["azure_id"]
display_name = user["display_name"]
personal_nr = user["personal_nr"]
hostname = user["hostname"]
segment = user["segment"]
last_attendance = user["last_attendance_status"] or ""
last_teams = user["last_teams_status"] or ""
phone_active = user["phone_active"]
# Immer Teams-Status abfragen (fuer Dashboard-Anzeige)
teams_status = ""
presence = graph.get_presence(azure_id)
if presence and "activity" in presence:
teams_status = "teams_" + presence["activity"].lower()
# Anwesenheitsstatus ermitteln
attendance_status = determine_attendance_status(attendance_lines, personal_nr, settings)
# Check if anything changed
if attendance_status == last_attendance and teams_status == last_teams:
return # No change
# --- 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:
if attendance_status == "abwesend":
color = get_color_for_status("abwesend", conn)
elif attendance_status == "homeoffice":
color = get_color_for_status("homeoffice", conn)
elif attendance_status == "anwesend" and teams_status:
color = get_color_for_status(teams_status, conn)
else:
color = get_color_for_status("available", conn)
url = build_wled_url(
hostname, color["r"], color["g"], color["b"],
segment=segment, effect=color.get("effect", "")
)
success, result_text = call_wled(url)
if not success:
logger.warning("WLED Fehler fuer %s: %s", display_name, result_text)
log_activity(
azure_id, display_name,
attendance_status or "-", teams_status or "-",
url, result_text,
)
# State immer aktualisieren (fuer Dashboard)
conn.execute("""
INSERT INTO state (user_azure_id, last_attendance_status, last_teams_status, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(user_azure_id)
DO UPDATE SET last_attendance_status=?, last_teams_status=?, updated_at=?
""", (
azure_id,
attendance_status or last_attendance, teams_status, datetime.now(),
attendance_status or last_attendance, teams_status, datetime.now(),
))
conn.commit()
logger.info(
"%-40s zeiterfassung=%-12s teams=%-20s wled=%s",
display_name, attendance_status or "-", teams_status,
"aktualisiert" if wled_changed else "nur Dashboard",
)
def check_devices():
"""Check all WLED devices and store status in DB. Called periodically."""
conn = get_db()
try:
devices = conn.execute("SELECT id, hostname FROM wled_devices").fetchall()
for dev in devices:
info = get_wled_info(dev["hostname"])
if info.get("online"):
conn.execute("""
UPDATE wled_devices
SET online=1, leds=?, version=?, mac=?, last_check=?
WHERE id=?
""", (
info.get("leds", 0),
info.get("version", ""),
info.get("mac", ""),
datetime.now(),
dev["id"],
))
else:
conn.execute("""
UPDATE wled_devices SET online=0, last_check=? WHERE id=?
""", (datetime.now(), dev["id"]))
conn.commit()
logger.info("Device-Check: %d Geraete geprueft", len(devices))
except Exception as e:
logger.error("Device-Check Fehler: %s", e)
finally:
conn.close()