feat: rewrite busylight as FastAPI web application with SQLite
Replace the standalone Python script + config.cfg with a full web application featuring: - FastAPI backend with REST API - SQLite database (config, state, activity log separated) - Bootstrap 5 web interface for configuration - Pages: Dashboard, Users, Devices, Assignments, Color Rules, Settings, Log - APScheduler background polling for Teams presence + AIDA status - systemd service file for Linux deployment - Migration script to import existing config.cfg data Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
5267baf0b4
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.env
|
||||
venv/
|
||||
busylight.db
|
||||
config.cfg
|
||||
config.dev.cfg
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.vscode/
|
||||
455
app.py
Normal file
455
app.py
Normal file
@ -0,0 +1,455 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Busylight WLED Controller - Web Application."""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request, Form
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
|
||||
import database
|
||||
import scheduler
|
||||
from graph_client import GraphAPI
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bg_scheduler = BackgroundScheduler()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(application: FastAPI):
|
||||
database.init_db()
|
||||
|
||||
# Init Graph client
|
||||
try:
|
||||
scheduler.graph = GraphAPI()
|
||||
except Exception as e:
|
||||
logger.error("Graph-Client konnte nicht initialisiert werden: %s", e)
|
||||
|
||||
# Start background polling
|
||||
interval = int(database.get_setting("poll_interval", "15"))
|
||||
bg_scheduler.add_job(scheduler.poll_status, "interval", seconds=interval, id="poll")
|
||||
bg_scheduler.start()
|
||||
logger.info("Polling gestartet (Intervall: %ds)", interval)
|
||||
|
||||
yield
|
||||
|
||||
bg_scheduler.shutdown()
|
||||
|
||||
|
||||
app = FastAPI(title="Busylight Manager", lifespan=lifespan)
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
# ── Dashboard ──────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def dashboard(request: Request):
|
||||
conn = database.get_db()
|
||||
devices = conn.execute("""
|
||||
SELECT d.id, d.hostname, d.name, d.segment_count
|
||||
FROM wled_devices d ORDER BY d.name, d.hostname
|
||||
""").fetchall()
|
||||
|
||||
device_data = []
|
||||
for dev in devices:
|
||||
segments = []
|
||||
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,
|
||||
cr_aida.r as ar, cr_aida.g as ag, cr_aida.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_teams ON cr_teams.status = s.last_teams_status
|
||||
WHERE a.wled_device_id = ? AND a.segment = ?
|
||||
""", (dev["id"], seg_idx)).fetchone()
|
||||
|
||||
if row:
|
||||
# Use teams color if anwesend, otherwise aida color
|
||||
if row["last_aida_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"]
|
||||
else:
|
||||
r, g, b = 128, 128, 128
|
||||
segments.append({
|
||||
"index": seg_idx,
|
||||
"user": row["display_name"],
|
||||
"aida": row["last_aida_status"] or "-",
|
||||
"teams": row["last_teams_status"] or "-",
|
||||
"r": r, "g": g, "b": b,
|
||||
})
|
||||
else:
|
||||
segments.append({
|
||||
"index": seg_idx, "user": None,
|
||||
"aida": "-", "teams": "-",
|
||||
"r": 128, "g": 128, "b": 128,
|
||||
})
|
||||
|
||||
device_data.append({
|
||||
"id": dev["id"],
|
||||
"hostname": dev["hostname"],
|
||||
"name": dev["name"] or dev["hostname"],
|
||||
"segments": segments,
|
||||
})
|
||||
conn.close()
|
||||
|
||||
authenticated = scheduler.graph is not None and scheduler.graph.is_authenticated()
|
||||
return templates.TemplateResponse("dashboard.html", {
|
||||
"request": request, "page": "dashboard",
|
||||
"devices": device_data, "authenticated": authenticated,
|
||||
})
|
||||
|
||||
|
||||
# ── Users ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/users", response_class=HTMLResponse)
|
||||
async def users_page(request: Request):
|
||||
conn = database.get_db()
|
||||
users = conn.execute("""
|
||||
SELECT u.*, a.id as assignment_id
|
||||
FROM users u LEFT JOIN assignments a ON a.user_azure_id = u.azure_id
|
||||
ORDER BY u.active DESC, u.display_name
|
||||
""").fetchall()
|
||||
conn.close()
|
||||
return templates.TemplateResponse("users.html", {
|
||||
"request": request, "page": "users", "users": users,
|
||||
})
|
||||
|
||||
|
||||
@app.post("/api/users/sync")
|
||||
async def sync_users():
|
||||
if scheduler.graph is None or not scheduler.graph.is_authenticated():
|
||||
return RedirectResponse("/users?error=not_authenticated", status_code=303)
|
||||
|
||||
azure_users = scheduler.graph.get_users()
|
||||
conn = database.get_db()
|
||||
count = 0
|
||||
for au in azure_users:
|
||||
existing = conn.execute(
|
||||
"SELECT azure_id FROM users WHERE azure_id = ?", (au["id"],)
|
||||
).fetchone()
|
||||
if not existing:
|
||||
conn.execute(
|
||||
"INSERT INTO users (azure_id, display_name, email) VALUES (?, ?, ?)",
|
||||
(au["id"], au.get("displayName", ""), au.get("mail", "")),
|
||||
)
|
||||
count += 1
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE users SET display_name=?, email=? WHERE azure_id=?",
|
||||
(au.get("displayName", ""), au.get("mail", ""), au["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return RedirectResponse(f"/users?synced={count}", status_code=303)
|
||||
|
||||
|
||||
@app.post("/api/users/{azure_id}/update")
|
||||
async def update_user(
|
||||
azure_id: str,
|
||||
personal_nr: str = Form(""),
|
||||
active: str = Form("0"),
|
||||
):
|
||||
conn = database.get_db()
|
||||
conn.execute(
|
||||
"UPDATE users SET personal_nr=?, active=? WHERE azure_id=?",
|
||||
(personal_nr, 1 if active == "1" else 0, azure_id),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return RedirectResponse("/users", status_code=303)
|
||||
|
||||
|
||||
@app.post("/api/users/{azure_id}/toggle")
|
||||
async def toggle_user(azure_id: str):
|
||||
conn = database.get_db()
|
||||
conn.execute(
|
||||
"UPDATE users SET active = CASE WHEN active=1 THEN 0 ELSE 1 END WHERE azure_id=?",
|
||||
(azure_id,),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return RedirectResponse("/users", status_code=303)
|
||||
|
||||
|
||||
# ── Devices ────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/devices", response_class=HTMLResponse)
|
||||
async def devices_page(request: Request):
|
||||
conn = database.get_db()
|
||||
devices = conn.execute(
|
||||
"SELECT * FROM wled_devices ORDER BY name, hostname"
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return templates.TemplateResponse("devices.html", {
|
||||
"request": request, "page": "devices", "devices": devices,
|
||||
})
|
||||
|
||||
|
||||
@app.post("/api/devices")
|
||||
async def add_device(
|
||||
hostname: str = Form(...),
|
||||
name: str = Form(""),
|
||||
segment_count: int = Form(2),
|
||||
):
|
||||
conn = database.get_db()
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO wled_devices (hostname, name, segment_count) VALUES (?, ?, ?)",
|
||||
(hostname, name or hostname, segment_count),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return RedirectResponse("/devices", status_code=303)
|
||||
|
||||
|
||||
@app.post("/api/devices/{device_id}/update")
|
||||
async def update_device(
|
||||
device_id: int,
|
||||
hostname: str = Form(...),
|
||||
name: str = Form(""),
|
||||
segment_count: int = Form(2),
|
||||
):
|
||||
conn = database.get_db()
|
||||
conn.execute(
|
||||
"UPDATE wled_devices SET hostname=?, name=?, segment_count=? WHERE id=?",
|
||||
(hostname, name, segment_count, device_id),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return RedirectResponse("/devices", status_code=303)
|
||||
|
||||
|
||||
@app.post("/api/devices/{device_id}/delete")
|
||||
async def delete_device(device_id: int):
|
||||
conn = database.get_db()
|
||||
conn.execute("DELETE FROM wled_devices WHERE id=?", (device_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return RedirectResponse("/devices", status_code=303)
|
||||
|
||||
|
||||
# ── Assignments ────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/assignments", response_class=HTMLResponse)
|
||||
async def assignments_page(request: Request):
|
||||
conn = database.get_db()
|
||||
assignments = conn.execute("""
|
||||
SELECT a.id, a.segment, u.display_name, u.azure_id,
|
||||
d.hostname, d.name as device_name, d.id as device_id
|
||||
FROM assignments a
|
||||
JOIN users u ON u.azure_id = a.user_azure_id
|
||||
JOIN wled_devices d ON d.id = a.wled_device_id
|
||||
ORDER BY d.name, a.segment
|
||||
""").fetchall()
|
||||
|
||||
users = conn.execute(
|
||||
"SELECT azure_id, display_name FROM users WHERE active=1 ORDER BY display_name"
|
||||
).fetchall()
|
||||
devices = conn.execute(
|
||||
"SELECT id, hostname, name, segment_count FROM wled_devices ORDER BY name"
|
||||
).fetchall()
|
||||
conn.close()
|
||||
|
||||
return templates.TemplateResponse("assignments.html", {
|
||||
"request": request, "page": "assignments",
|
||||
"assignments": assignments, "users": users, "devices": devices,
|
||||
})
|
||||
|
||||
|
||||
@app.post("/api/assignments")
|
||||
async def create_assignment(
|
||||
user_azure_id: str = Form(...),
|
||||
wled_device_id: int = Form(...),
|
||||
segment: int = Form(0),
|
||||
):
|
||||
conn = database.get_db()
|
||||
# Remove existing assignment for this user
|
||||
conn.execute("DELETE FROM assignments WHERE user_azure_id=?", (user_azure_id,))
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO assignments (user_azure_id, wled_device_id, segment) VALUES (?, ?, ?)",
|
||||
(user_azure_id, wled_device_id, segment),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return RedirectResponse("/assignments", status_code=303)
|
||||
|
||||
|
||||
@app.post("/api/assignments/{assignment_id}/delete")
|
||||
async def delete_assignment(assignment_id: int):
|
||||
conn = database.get_db()
|
||||
conn.execute("DELETE FROM assignments WHERE id=?", (assignment_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return RedirectResponse("/assignments", status_code=303)
|
||||
|
||||
|
||||
# ── Color Rules ────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/colors", response_class=HTMLResponse)
|
||||
async def colors_page(request: Request):
|
||||
conn = database.get_db()
|
||||
rules = conn.execute("SELECT * FROM color_rules ORDER BY status").fetchall()
|
||||
conn.close()
|
||||
return templates.TemplateResponse("colors.html", {
|
||||
"request": request, "page": "colors", "rules": rules,
|
||||
})
|
||||
|
||||
|
||||
@app.post("/api/colors")
|
||||
async def update_colors(request: Request):
|
||||
form = await request.form()
|
||||
conn = database.get_db()
|
||||
|
||||
statuses = form.getlist("status")
|
||||
for status in statuses:
|
||||
color_hex = form.get(f"color_{status}", "#000000")
|
||||
# Convert hex to RGB
|
||||
color_hex = color_hex.lstrip("#")
|
||||
r = int(color_hex[0:2], 16)
|
||||
g = int(color_hex[2:4], 16)
|
||||
b = int(color_hex[4:6], 16)
|
||||
effect = form.get(f"effect_{status}", "")
|
||||
|
||||
conn.execute(
|
||||
"UPDATE color_rules SET r=?, g=?, b=?, effect=? WHERE status=?",
|
||||
(r, g, b, effect, status),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return RedirectResponse("/colors?saved=1", status_code=303)
|
||||
|
||||
|
||||
@app.post("/api/colors/add")
|
||||
async def add_color(
|
||||
status: str = Form(...),
|
||||
color: str = Form("#00ff00"),
|
||||
effect: str = Form(""),
|
||||
):
|
||||
color_hex = color.lstrip("#")
|
||||
r = int(color_hex[0:2], 16)
|
||||
g = int(color_hex[2:4], 16)
|
||||
b = int(color_hex[4:6], 16)
|
||||
|
||||
conn = database.get_db()
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO color_rules (status, r, g, b, effect) VALUES (?, ?, ?, ?, ?)",
|
||||
(status, r, g, b, effect),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return RedirectResponse("/colors", status_code=303)
|
||||
|
||||
|
||||
# ── Settings ───────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/settings", response_class=HTMLResponse)
|
||||
async def settings_page(request: Request):
|
||||
settings = database.get_all_settings()
|
||||
authenticated = scheduler.graph is not None and scheduler.graph.is_authenticated()
|
||||
return templates.TemplateResponse("settings.html", {
|
||||
"request": request, "page": "settings",
|
||||
"settings": settings, "authenticated": authenticated,
|
||||
})
|
||||
|
||||
|
||||
@app.post("/api/settings")
|
||||
async def update_settings(request: Request):
|
||||
form = await request.form()
|
||||
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",
|
||||
]
|
||||
for key in keys:
|
||||
value = form.get(key, "")
|
||||
database.set_setting(key, value)
|
||||
|
||||
# Update scheduler interval
|
||||
try:
|
||||
interval = int(form.get("poll_interval", "15"))
|
||||
bg_scheduler.reschedule_job("poll", trigger="interval", seconds=interval)
|
||||
logger.info("Polling-Intervall geaendert auf %ds", interval)
|
||||
except Exception as e:
|
||||
logger.error("Fehler beim Aendern des Intervalls: %s", e)
|
||||
|
||||
return RedirectResponse("/settings?saved=1", status_code=303)
|
||||
|
||||
|
||||
# ── Log ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/log", response_class=HTMLResponse)
|
||||
async def log_page(request: Request):
|
||||
conn = database.get_db()
|
||||
logs = conn.execute(
|
||||
"SELECT * FROM activity_log ORDER BY id DESC LIMIT 200"
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return templates.TemplateResponse("log.html", {
|
||||
"request": request, "page": "log", "logs": logs,
|
||||
})
|
||||
|
||||
|
||||
# ── API Status (for dashboard auto-refresh) ───────────────────────────────
|
||||
|
||||
@app.get("/api/status")
|
||||
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,
|
||||
s.updated_at, d.hostname, a.segment
|
||||
FROM state s
|
||||
JOIN users u ON u.azure_id = s.user_azure_id
|
||||
LEFT JOIN assignments a ON a.user_azure_id = u.azure_id
|
||||
LEFT JOIN wled_devices d ON d.id = a.wled_device_id
|
||||
ORDER BY u.display_name
|
||||
""").fetchall()
|
||||
conn.close()
|
||||
return [dict(s) for s in states]
|
||||
|
||||
|
||||
@app.post("/api/poll")
|
||||
async def trigger_poll():
|
||||
"""Trigger a manual poll."""
|
||||
scheduler.poll_status()
|
||||
return RedirectResponse("/", status_code=303)
|
||||
|
||||
|
||||
# ── CLI Auth Command ───────────────────────────────────────────────────────
|
||||
|
||||
def cli_auth():
|
||||
"""Run Azure device code authentication from terminal."""
|
||||
database.init_db()
|
||||
graph = GraphAPI()
|
||||
if graph.is_authenticated():
|
||||
print("Bereits authentifiziert.")
|
||||
return
|
||||
print("Starte Azure Device Code Authentifizierung...")
|
||||
print("Bitte den angezeigten Code im Browser eingeben.")
|
||||
graph.authenticate()
|
||||
print("Authentifizierung erfolgreich!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "--auth":
|
||||
cli_auth()
|
||||
else:
|
||||
uvicorn.run(app, host="0.0.0.0", port=8080)
|
||||
17
busylight.service
Normal file
17
busylight.service
Normal file
@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=Busylight WLED Controller
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=busylight
|
||||
Group=busylight
|
||||
WorkingDirectory=/opt/busylight
|
||||
ExecStart=/opt/busylight/venv/bin/python app.py
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
157
database.py
Normal file
157
database.py
Normal file
@ -0,0 +1,157 @@
|
||||
import sqlite3
|
||||
import os
|
||||
|
||||
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "busylight.db")
|
||||
|
||||
|
||||
def get_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
return conn
|
||||
|
||||
|
||||
def init_db():
|
||||
conn = get_db()
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
azure_id TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
email TEXT DEFAULT '',
|
||||
personal_nr TEXT DEFAULT '',
|
||||
active INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wled_devices (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
hostname TEXT NOT NULL UNIQUE,
|
||||
name TEXT DEFAULT '',
|
||||
segment_count INTEGER NOT NULL DEFAULT 2
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS assignments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_azure_id TEXT NOT NULL 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,
|
||||
UNIQUE(wled_device_id, segment),
|
||||
UNIQUE(user_azure_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS color_rules (
|
||||
status TEXT PRIMARY KEY,
|
||||
r INTEGER NOT NULL DEFAULT 0,
|
||||
g INTEGER NOT NULL DEFAULT 0,
|
||||
b INTEGER NOT NULL DEFAULT 0,
|
||||
effect TEXT DEFAULT ''
|
||||
);
|
||||
|
||||
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_teams_status TEXT DEFAULT '',
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS activity_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
user_azure_id TEXT,
|
||||
user_name TEXT,
|
||||
aida_status TEXT,
|
||||
teams_status TEXT,
|
||||
wled_url TEXT,
|
||||
result TEXT
|
||||
);
|
||||
""")
|
||||
|
||||
defaults = {
|
||||
"azure_client_id": "",
|
||||
"azure_client_secret": "",
|
||||
"azure_tenant_id": "",
|
||||
"azure_auth_tenant": "common",
|
||||
"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~~",
|
||||
}
|
||||
for key, value in defaults.items():
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)",
|
||||
(key, value),
|
||||
)
|
||||
|
||||
default_colors = [
|
||||
("abwesend", 0, 0, 0, ""),
|
||||
("homeoffice", 255, 255, 0, ""),
|
||||
("available", 0, 255, 0, ""),
|
||||
("teams_available", 0, 255, 0, ""),
|
||||
("teams_away", 0, 255, 0, ""),
|
||||
("teams_busy", 255, 0, 0, ""),
|
||||
("teams_donotdisturb", 255, 0, 255, ""),
|
||||
("teams_inacall", 255, 0, 255, ""),
|
||||
("teams_inameeting", 255, 0, 0, ""),
|
||||
("teams_presenting", 200, 200, 200, ""),
|
||||
("teams_inaconferencecall", 200, 200, 200, ""),
|
||||
("teams_berightback", 0, 255, 0, ""),
|
||||
("teams_offline", 0, 255, 0, ""),
|
||||
("teams_inactive", 0, 255, 0, ""),
|
||||
("teams_offwork", 0, 255, 0, ""),
|
||||
("teams_outofoffice", 0, 255, 0, ""),
|
||||
]
|
||||
for status, r, g, b, effect in default_colors:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO color_rules (status, r, g, b, effect) VALUES (?, ?, ?, ?, ?)",
|
||||
(status, r, g, b, effect),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_setting(key, default=""):
|
||||
conn = get_db()
|
||||
row = conn.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
|
||||
conn.close()
|
||||
return row["value"] if row else default
|
||||
|
||||
|
||||
def set_setting(key, value):
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
|
||||
(key, str(value)),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_all_settings():
|
||||
conn = get_db()
|
||||
rows = conn.execute("SELECT key, value FROM settings").fetchall()
|
||||
conn.close()
|
||||
return {row["key"]: row["value"] for row in rows}
|
||||
|
||||
|
||||
def log_activity(user_azure_id, user_name, aida_status, teams_status, wled_url, result):
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"""INSERT INTO activity_log
|
||||
(user_azure_id, user_name, aida_status, teams_status, wled_url, result)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(user_azure_id, user_name, aida_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)"
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
102
graph_client.py
Normal file
102
graph_client.py
Normal file
@ -0,0 +1,102 @@
|
||||
import logging
|
||||
from azure.identity import (
|
||||
DeviceCodeCredential,
|
||||
TokenCachePersistenceOptions,
|
||||
AuthenticationRecord,
|
||||
)
|
||||
from msgraph.core import GraphClient
|
||||
from database import get_setting, set_setting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CACHE_OPTIONS = TokenCachePersistenceOptions(
|
||||
name="busylight", allow_unencrypted_cache=True
|
||||
)
|
||||
|
||||
|
||||
class GraphAPI:
|
||||
def __init__(self):
|
||||
self.client = None
|
||||
self.credential = None
|
||||
self._init_client()
|
||||
|
||||
def _init_client(self):
|
||||
client_id = get_setting("azure_client_id")
|
||||
tenant_id = get_setting("azure_auth_tenant", "common")
|
||||
scopes = get_setting("azure_scopes").split()
|
||||
auth_record_json = get_setting("azure_auth_record")
|
||||
|
||||
if not client_id or not auth_record_json:
|
||||
logger.warning("Azure nicht konfiguriert oder nicht authentifiziert")
|
||||
return
|
||||
|
||||
try:
|
||||
record = AuthenticationRecord.deserialize(auth_record_json)
|
||||
self.credential = DeviceCodeCredential(
|
||||
client_id=client_id,
|
||||
tenant_id=tenant_id,
|
||||
enable_persistent_cache=True,
|
||||
allow_unencrypted_cache=True,
|
||||
cache_persistence_options=CACHE_OPTIONS,
|
||||
authentication_record=record,
|
||||
)
|
||||
self.credential.authenticate(tenant_id=tenant_id, scopes=scopes)
|
||||
self.client = GraphClient(credential=self.credential, scopes=scopes)
|
||||
logger.info("Graph-Client erfolgreich initialisiert")
|
||||
except Exception as e:
|
||||
logger.error("Graph-Client Fehler: %s", e)
|
||||
self.client = None
|
||||
|
||||
def authenticate(self):
|
||||
"""Trigger device code flow. Must run in terminal."""
|
||||
client_id = get_setting("azure_client_id")
|
||||
tenant_id = get_setting("azure_auth_tenant", "common")
|
||||
scopes = get_setting("azure_scopes").split()
|
||||
|
||||
if not client_id:
|
||||
raise ValueError("Azure Client ID nicht konfiguriert")
|
||||
|
||||
self.credential = DeviceCodeCredential(
|
||||
client_id=client_id,
|
||||
tenant_id=tenant_id,
|
||||
enable_persistent_cache=True,
|
||||
allow_unencrypted_cache=True,
|
||||
cache_persistence_options=CACHE_OPTIONS,
|
||||
)
|
||||
record = self.credential.authenticate(tenant_id=tenant_id, scopes=scopes)
|
||||
set_setting("azure_auth_record", record.serialize())
|
||||
self.client = GraphClient(credential=self.credential, scopes=scopes)
|
||||
logger.info("Azure Authentifizierung erfolgreich")
|
||||
return record
|
||||
|
||||
def is_authenticated(self):
|
||||
return self.client is not None
|
||||
|
||||
def get_users(self):
|
||||
if not self.client:
|
||||
return []
|
||||
try:
|
||||
resp = self.client.get(
|
||||
"/users?$select=displayName,id,mail&$orderBy=displayName&$top=999"
|
||||
)
|
||||
data = resp.json()
|
||||
users = data.get("value", [])
|
||||
# Handle pagination
|
||||
while "@odata.nextLink" in data:
|
||||
resp = self.client.get(data["@odata.nextLink"])
|
||||
data = resp.json()
|
||||
users.extend(data.get("value", []))
|
||||
return users
|
||||
except Exception as e:
|
||||
logger.error("Fehler beim Abrufen der User: %s", e)
|
||||
return []
|
||||
|
||||
def get_presence(self, user_id):
|
||||
if not self.client:
|
||||
return None
|
||||
try:
|
||||
resp = self.client.get(f"/users/{user_id}/presence")
|
||||
return resp.json()
|
||||
except Exception as e:
|
||||
logger.error("Fehler beim Abrufen der Presence für %s: %s", user_id, e)
|
||||
return None
|
||||
148
migrate.py
Normal file
148
migrate.py
Normal file
@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Migrate old config.cfg to SQLite database."""
|
||||
|
||||
import re
|
||||
import sys
|
||||
from configparser import ConfigParser
|
||||
from database import init_db, get_db, set_setting
|
||||
|
||||
|
||||
def parse_color(value):
|
||||
"""Parse 'R=255&G=0&B=0' or '/win&A=255&R=200&G=200&B=200' into (r, g, b, effect)."""
|
||||
effect = ""
|
||||
# Extract effect prefix (e.g., /win&A=255)
|
||||
if value.startswith("/"):
|
||||
parts = value.split("&R=")
|
||||
if len(parts) == 2:
|
||||
effect = parts[0]
|
||||
value = "R=" + parts[1]
|
||||
|
||||
r = g = b = 0
|
||||
for part in value.replace("&", " ").split():
|
||||
if part.startswith("R="):
|
||||
r = int(part[2:])
|
||||
elif part.startswith("G="):
|
||||
g = int(part[2:])
|
||||
elif part.startswith("B="):
|
||||
b = int(part[2:])
|
||||
return r, g, b, effect
|
||||
|
||||
|
||||
def migrate(config_path="config.cfg"):
|
||||
"""Import old config.cfg into the SQLite database."""
|
||||
config = ConfigParser()
|
||||
config.read(config_path, encoding="utf-8")
|
||||
|
||||
init_db()
|
||||
conn = get_db()
|
||||
|
||||
# Azure settings
|
||||
if config.has_section("azure"):
|
||||
az = config["azure"]
|
||||
set_setting("azure_client_id", az.get("clientid", ""))
|
||||
set_setting("azure_client_secret", az.get("clientsecret", ""))
|
||||
set_setting("azure_tenant_id", az.get("tenantid", ""))
|
||||
set_setting("azure_auth_tenant", az.get("authtenant", "common"))
|
||||
set_setting("azure_scopes", az.get("graphuserscopes", ""))
|
||||
|
||||
# Auth record
|
||||
if config.has_option("cred", "value"):
|
||||
set_setting("azure_auth_record", config["cred"]["value"])
|
||||
|
||||
# Config settings
|
||||
if config.has_section("config"):
|
||||
cfg = config["config"]
|
||||
set_setting("poll_interval", cfg.get("intervall", "15"))
|
||||
set_setting("aida_csv_path", cfg.get("anwesenheit_csv", ""))
|
||||
set_setting("aida_status_anwesend", cfg.get("anwesenheit_anwesend", "~~1~~"))
|
||||
set_setting("aida_status_abwesend", cfg.get("anwesenheit_abwesend", "~~0~~"))
|
||||
set_setting("aida_status_homeoffice", cfg.get("anwesenheit_homeoffice", "~~2~~"))
|
||||
|
||||
# WLED color rules
|
||||
if config.has_section("wled"):
|
||||
for status, value in config["wled"].items():
|
||||
r, g, b, effect = parse_color(value)
|
||||
conn.execute(
|
||||
"""INSERT OR REPLACE INTO color_rules (status, r, g, b, effect)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
(status, r, g, b, effect),
|
||||
)
|
||||
|
||||
# Users and WLED devices
|
||||
devices = {} # hostname -> device_id
|
||||
if config.has_section("user"):
|
||||
for azure_id, entry in config["user"].items():
|
||||
parts = entry.split(" - ", 1)
|
||||
hostname = parts[0].strip()
|
||||
display_name = parts[1].strip() if len(parts) > 1 else ""
|
||||
|
||||
# Get personal number from anwesenheit section
|
||||
personal_nr = ""
|
||||
if config.has_option("anwesenheit", azure_id):
|
||||
anw_val = config["anwesenheit"][azure_id].split()[0]
|
||||
personal_nr = anw_val
|
||||
|
||||
# Determine if user is active (has a known hostname)
|
||||
is_active = 0 if hostname == "unbekannt" else 1
|
||||
|
||||
conn.execute(
|
||||
"""INSERT OR REPLACE INTO users (azure_id, display_name, personal_nr, active)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
(azure_id, display_name, personal_nr, is_active),
|
||||
)
|
||||
|
||||
# Create WLED device if hostname is known
|
||||
if hostname != "unbekannt" and hostname not in devices:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO wled_devices (hostname, name, segment_count) VALUES (?, ?, 2)",
|
||||
(hostname, hostname),
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT id FROM wled_devices WHERE hostname = ?", (hostname,)
|
||||
).fetchone()
|
||||
if row:
|
||||
devices[hostname] = row["id"]
|
||||
|
||||
# Segment assignments
|
||||
if config.has_section("segment"):
|
||||
for azure_id, entry in config["segment"].items():
|
||||
parts = entry.split()
|
||||
seg_str = parts[0] if parts else "-"
|
||||
if seg_str == "-":
|
||||
continue
|
||||
|
||||
try:
|
||||
segment = int(seg_str)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
# Find the user's hostname
|
||||
if config.has_option("user", azure_id):
|
||||
user_entry = config["user"][azure_id]
|
||||
hostname = user_entry.split(" - ", 1)[0].strip()
|
||||
if hostname in devices:
|
||||
device_id = devices[hostname]
|
||||
conn.execute(
|
||||
"""INSERT OR REPLACE INTO assignments
|
||||
(user_azure_id, wled_device_id, segment)
|
||||
VALUES (?, ?, ?)""",
|
||||
(azure_id, device_id, segment),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print(f"Migration abgeschlossen.")
|
||||
print(f" - {len(devices)} WLED-Geraete importiert")
|
||||
|
||||
conn = get_db()
|
||||
user_count = conn.execute("SELECT COUNT(*) as c FROM users").fetchone()["c"]
|
||||
active_count = conn.execute("SELECT COUNT(*) as c FROM users WHERE active=1").fetchone()["c"]
|
||||
assign_count = conn.execute("SELECT COUNT(*) as c FROM assignments").fetchone()["c"]
|
||||
conn.close()
|
||||
print(f" - {user_count} User importiert ({active_count} aktiv)")
|
||||
print(f" - {assign_count} Segment-Zuordnungen importiert")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
path = sys.argv[1] if len(sys.argv) > 1 else "config.cfg"
|
||||
migrate(path)
|
||||
8
requirements.txt
Normal file
8
requirements.txt
Normal file
@ -0,0 +1,8 @@
|
||||
fastapi>=0.104.0
|
||||
uvicorn[standard]>=0.24.0
|
||||
jinja2>=3.1.0
|
||||
python-multipart>=0.0.6
|
||||
requests>=2.31.0
|
||||
azure-identity>=1.15.0
|
||||
msgraph-core>=0.2.2
|
||||
apscheduler>=3.10.0
|
||||
184
scheduler.py
Normal file
184
scheduler.py
Normal file
@ -0,0 +1,184 @@
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Global reference to graph client (set by app.py on startup)
|
||||
graph = None
|
||||
|
||||
|
||||
def read_aida_csv(csv_path):
|
||||
"""Read AIDA status CSV. Returns dict: personal_nr -> line content."""
|
||||
if not csv_path or not os.path.exists(csv_path):
|
||||
logger.warning("AIDA CSV nicht gefunden: %s", 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 AIDA CSV: %s", e)
|
||||
return []
|
||||
|
||||
|
||||
def determine_aida_status(lines, personal_nr, settings):
|
||||
"""Determine AIDA status for a user based on their personal number."""
|
||||
if not personal_nr or not lines:
|
||||
return None
|
||||
|
||||
matching = [l for l in lines if l.startswith(personal_nr.lower())]
|
||||
if not matching:
|
||||
return None
|
||||
|
||||
line = matching[0]
|
||||
if line.endswith(settings["aida_status_abwesend"]):
|
||||
return "abwesend"
|
||||
if line.endswith(settings["aida_status_anwesend"]):
|
||||
return "anwesend"
|
||||
if line.endswith(settings["aida_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_aida_status, s.last_teams_status
|
||||
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 AND u.personal_nr != ''
|
||||
""").fetchall()
|
||||
|
||||
if not users_with_assignments:
|
||||
logger.debug("Keine aktiven User mit Zuordnungen gefunden")
|
||||
return
|
||||
|
||||
# Read AIDA CSV
|
||||
aida_lines = read_aida_csv(settings.get("aida_csv_path", ""))
|
||||
|
||||
for user in users_with_assignments:
|
||||
try:
|
||||
_process_user(user, aida_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, aida_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_aida = user["last_aida_status"] or ""
|
||||
last_teams = user["last_teams_status"] or ""
|
||||
|
||||
# Determine AIDA status
|
||||
aida_status = determine_aida_status(aida_lines, personal_nr, settings)
|
||||
if aida_status is None:
|
||||
return
|
||||
|
||||
# Determine Teams status
|
||||
teams_status = ""
|
||||
if aida_status == "anwesend":
|
||||
presence = graph.get_presence(azure_id)
|
||||
if presence and "activity" in presence:
|
||||
teams_status = "teams_" + presence["activity"].lower()
|
||||
|
||||
# Check if status changed
|
||||
if aida_status == last_aida:
|
||||
if aida_status == "anwesend":
|
||||
if teams_status == last_teams:
|
||||
return # No change
|
||||
else:
|
||||
return # No change
|
||||
|
||||
# Determine color
|
||||
if aida_status == "abwesend":
|
||||
color = get_color_for_status("abwesend", conn)
|
||||
elif aida_status == "homeoffice":
|
||||
color = get_color_for_status("homeoffice", conn)
|
||||
elif aida_status == "anwesend" and teams_status:
|
||||
color = get_color_for_status(teams_status, conn)
|
||||
if not color:
|
||||
color = get_color_for_status("teams_available", conn)
|
||||
else:
|
||||
color = get_color_for_status("available", conn)
|
||||
|
||||
# Build URL and call WLED
|
||||
url = build_wled_url(
|
||||
hostname, color["r"], color["g"], color["b"],
|
||||
segment=segment, effect=color.get("effect", "")
|
||||
)
|
||||
success, result_text = call_wled(url)
|
||||
|
||||
if success:
|
||||
# Update state
|
||||
conn.execute("""
|
||||
INSERT INTO state (user_azure_id, last_aida_status, last_teams_status, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(user_azure_id)
|
||||
DO UPDATE SET last_aida_status=?, last_teams_status=?, updated_at=?
|
||||
""", (
|
||||
azure_id, aida_status, teams_status, datetime.now(),
|
||||
aida_status, teams_status, datetime.now(),
|
||||
))
|
||||
conn.commit()
|
||||
logger.info(
|
||||
"%-40s aida=%-12s teams=%-20s -> %s",
|
||||
display_name, aida_status, teams_status, result_text,
|
||||
)
|
||||
else:
|
||||
# Reset state on failure
|
||||
conn.execute("""
|
||||
INSERT INTO state (user_azure_id, last_aida_status, last_teams_status, updated_at)
|
||||
VALUES (?, '', '', ?)
|
||||
ON CONFLICT(user_azure_id)
|
||||
DO UPDATE SET last_aida_status='', last_teams_status='', updated_at=?
|
||||
""", (azure_id, datetime.now(), datetime.now()))
|
||||
conn.commit()
|
||||
|
||||
# Log activity
|
||||
log_activity(
|
||||
azure_id, display_name, aida_status, teams_status, url, result_text
|
||||
)
|
||||
131
static/style.css
Normal file
131
static/style.css
Normal file
@ -0,0 +1,131 @@
|
||||
:root {
|
||||
--sidebar-width: 240px;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
background: #f4f6f9;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
min-height: 100vh;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 100;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar .nav-link {
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
margin: 2px 8px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.sidebar .nav-link:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.sidebar .nav-link.active {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sidebar .nav-link i {
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: var(--sidebar-width);
|
||||
min-height: 100vh;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
border: 1px solid rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.busylight-card {
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.busylight-card:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.segment-bar {
|
||||
height: 40px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
color: #333;
|
||||
font-size: 0.9rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.segment-bar.empty {
|
||||
background: repeating-linear-gradient(
|
||||
45deg,
|
||||
#f0f0f0,
|
||||
#f0f0f0 5px,
|
||||
#e8e8e8 5px,
|
||||
#e8e8e8 10px
|
||||
);
|
||||
color: #999;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.color-preview {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
border: 2px solid rgba(0, 0, 0, 0.15);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.table th {
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.badge-online {
|
||||
background: #28a745;
|
||||
}
|
||||
|
||||
.badge-offline {
|
||||
background: #6c757d;
|
||||
}
|
||||
|
||||
.log-table {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.log-table td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
min-height: auto;
|
||||
position: relative;
|
||||
}
|
||||
.main-content {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
115
templates/assignments.html
Normal file
115
templates/assignments.html
Normal file
@ -0,0 +1,115 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Zuordnungen - Busylight{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h4 class="mb-4">Zuordnungen (Benutzer → WLED-Segment)</h4>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Benutzer</th>
|
||||
<th>WLED-Geraet</th>
|
||||
<th>Segment</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for a in assignments %}
|
||||
<tr>
|
||||
<td>{{ a.display_name }}</td>
|
||||
<td>
|
||||
<code>{{ a.hostname }}</code>
|
||||
{% if a.device_name and a.device_name != a.hostname %}
|
||||
<small class="text-muted">({{ a.device_name }})</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-primary">Segment {{ a.segment }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" action="/api/assignments/{{ a.id }}/delete" class="d-inline">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
{% if not assignments %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center text-muted py-4">
|
||||
Noch keine Zuordnungen vorhanden.
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0"><i class="bi bi-plus-circle"></i> Neue Zuordnung</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" action="/api/assignments">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Benutzer</label>
|
||||
<select name="user_azure_id" class="form-select" required>
|
||||
<option value="">-- Benutzer waehlen --</option>
|
||||
{% for u in users %}
|
||||
<option value="{{ u.azure_id }}">{{ u.display_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">WLED-Geraet</label>
|
||||
<select name="wled_device_id" class="form-select" id="device-select" required>
|
||||
<option value="">-- Geraet waehlen --</option>
|
||||
{% for d in devices %}
|
||||
<option value="{{ d.id }}" data-segments="{{ d.segment_count }}">
|
||||
{{ d.name or d.hostname }} ({{ d.hostname }})
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Segment</label>
|
||||
<select name="segment" class="form-select" id="segment-select" required>
|
||||
<option value="0">Segment 0 (oben)</option>
|
||||
<option value="1">Segment 1 (unten)</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="bi bi-link-45deg"></i> Zuordnen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.getElementById('device-select').addEventListener('change', function() {
|
||||
const opt = this.selectedOptions[0];
|
||||
const count = parseInt(opt.dataset.segments || '2');
|
||||
const segSelect = document.getElementById('segment-select');
|
||||
segSelect.innerHTML = '';
|
||||
for (let i = 0; i < count; i++) {
|
||||
const label = i === 0 ? 'oben' : i === 1 ? 'unten' : i.toString();
|
||||
segSelect.innerHTML += `<option value="${i}">Segment ${i} (${label})</option>`;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
73
templates/base.html
Normal file
73
templates/base.html
Normal file
@ -0,0 +1,73 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Busylight Manager{% endblock %}</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">
|
||||
<link href="/static/style.css" rel="stylesheet">
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<div class="d-flex">
|
||||
<nav class="sidebar bg-dark text-white d-flex flex-column">
|
||||
<div class="p-3 border-bottom border-secondary">
|
||||
<h5 class="mb-0"><i class="bi bi-lightbulb-fill text-warning"></i> Busylight</h5>
|
||||
<small class="text-secondary">WLED Controller</small>
|
||||
</div>
|
||||
<ul class="nav flex-column mt-2 flex-grow-1">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-white {% if page == 'dashboard' %}active{% endif %}" href="/">
|
||||
<i class="bi bi-speedometer2"></i> Dashboard
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-white {% if page == 'users' %}active{% endif %}" href="/users">
|
||||
<i class="bi bi-people"></i> Benutzer
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-white {% if page == 'devices' %}active{% endif %}" href="/devices">
|
||||
<i class="bi bi-router"></i> WLED-Geraete
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-white {% if page == 'assignments' %}active{% endif %}" href="/assignments">
|
||||
<i class="bi bi-link-45deg"></i> Zuordnungen
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-white {% if page == 'colors' %}active{% endif %}" href="/colors">
|
||||
<i class="bi bi-palette"></i> Farb-Regeln
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-white {% if page == 'settings' %}active{% endif %}" href="/settings">
|
||||
<i class="bi bi-gear"></i> Einstellungen
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-white {% if page == 'log' %}active{% endif %}" href="/log">
|
||||
<i class="bi bi-journal-text"></i> Protokoll
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="p-3 border-top border-secondary">
|
||||
{% if authenticated %}
|
||||
<small class="text-success"><i class="bi bi-check-circle"></i> Azure verbunden</small>
|
||||
{% else %}
|
||||
<small class="text-danger"><i class="bi bi-x-circle"></i> Azure getrennt</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="main-content flex-grow-1">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
79
templates/colors.html
Normal file
79
templates/colors.html
Normal file
@ -0,0 +1,79 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Farb-Regeln - Busylight{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h4 class="mb-4">Farb-Regeln</h4>
|
||||
|
||||
{% if request.query_params.get('saved') %}
|
||||
<div class="alert alert-success alert-dismissible fade show">
|
||||
<i class="bi bi-check-circle"></i> Farben gespeichert.
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/api/colors">
|
||||
<div class="row g-3">
|
||||
{% for rule in rules %}
|
||||
<div class="col-md-6 col-lg-4 col-xl-3">
|
||||
<div class="card busylight-card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<div>
|
||||
<strong>{{ rule.status }}</strong>
|
||||
<input type="hidden" name="status" value="{{ rule.status }}">
|
||||
</div>
|
||||
<div class="color-preview"
|
||||
style="background: rgb({{ rule.r }}, {{ rule.g }}, {{ rule.b }});"
|
||||
title="Aktuelle Farbe"></div>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small text-muted mb-1">Farbe</label>
|
||||
<input type="color" name="color_{{ rule.status }}" class="form-control form-control-color w-100"
|
||||
value="#{{ '%02x%02x%02x' % (rule.r, rule.g, rule.b) | default('000000') }}">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label small text-muted mb-1">Effekt (optional)</label>
|
||||
<input type="text" name="effect_{{ rule.status }}" class="form-control form-control-sm"
|
||||
value="{{ rule.effect or '' }}" placeholder="z.B. /win&A=255">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-lg"></i> Alle Farben speichern
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<div class="card" style="max-width: 400px;">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0"><i class="bi bi-plus-circle"></i> Neue Farb-Regel</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" action="/api/colors/add">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Status-Name</label>
|
||||
<input type="text" name="status" class="form-control"
|
||||
placeholder="z.B. teams_focusing" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Farbe</label>
|
||||
<input type="color" name="color" class="form-control form-control-color w-100"
|
||||
value="#00ff00">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Effekt (optional)</label>
|
||||
<input type="text" name="effect" class="form-control"
|
||||
placeholder="z.B. /win&A=255">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-outline-primary w-100">Hinzufuegen</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
74
templates/dashboard.html
Normal file
74
templates/dashboard.html
Normal file
@ -0,0 +1,74 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard - Busylight{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h4 class="mb-0">Dashboard</h4>
|
||||
<div>
|
||||
<form method="post" action="/api/poll" class="d-inline">
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-arrow-clockwise"></i> Jetzt abfragen
|
||||
</button>
|
||||
</form>
|
||||
<span class="text-muted ms-2" id="last-update"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% 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.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if devices %}
|
||||
<div class="row g-3">
|
||||
{% for device in devices %}
|
||||
<div class="col-md-6 col-lg-4 col-xl-3">
|
||||
<div class="card busylight-card h-100">
|
||||
<div class="card-header bg-white border-0 pb-0">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<strong>{{ device.name }}</strong>
|
||||
<small class="text-muted">{{ device.hostname }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body pt-2">
|
||||
{% for seg in device.segments %}
|
||||
<div class="mb-2">
|
||||
<small class="text-muted d-block mb-1">Segment {{ seg.index }}</small>
|
||||
{% if seg.user %}
|
||||
<div class="segment-bar" style="background: rgba({{ seg.r }}, {{ seg.g }}, {{ seg.b }}, 0.3); border-color: rgb({{ seg.r }}, {{ seg.g }}, {{ seg.b }});">
|
||||
<span class="status-dot me-2" style="background: rgb({{ seg.r }}, {{ seg.g }}, {{ seg.b }});"></span>
|
||||
{{ seg.user }}
|
||||
</div>
|
||||
<small class="text-muted">{{ seg.aida }} / {{ seg.teams }}</small>
|
||||
{% else %}
|
||||
<div class="segment-bar empty">
|
||||
<i class="bi bi-dash-circle me-2"></i> Nicht zugeordnet
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-lightbulb display-1 text-muted"></i>
|
||||
<p class="text-muted mt-3">Noch keine WLED-Geraete konfiguriert.</p>
|
||||
<a href="/devices" class="btn btn-primary">Geraete hinzufuegen</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// Auto-refresh every 10 seconds
|
||||
setTimeout(() => location.reload(), 10000);
|
||||
document.getElementById('last-update').textContent =
|
||||
'Aktualisiert: ' + new Date().toLocaleTimeString('de-DE');
|
||||
</script>
|
||||
{% endblock %}
|
||||
124
templates/devices.html
Normal file
124
templates/devices.html
Normal file
@ -0,0 +1,124 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}WLED-Geraete - Busylight{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h4 class="mb-4">WLED-Geraete</h4>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Hostname / IP</th>
|
||||
<th>Name</th>
|
||||
<th>Segmente</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for device in devices %}
|
||||
<tr>
|
||||
<td>
|
||||
<code>{{ device.hostname }}</code>
|
||||
</td>
|
||||
<td>{{ device.name }}</td>
|
||||
<td>{{ device.segment_count }}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal"
|
||||
data-bs-target="#edit-{{ device.id }}">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<form method="post" action="/api/devices/{{ device.id }}/delete"
|
||||
class="d-inline"
|
||||
onsubmit="return confirm('Geraet wirklich loeschen?')">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Edit Modal -->
|
||||
<div class="modal fade" id="edit-{{ device.id }}" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form method="post" action="/api/devices/{{ device.id }}/update">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Geraet bearbeiten</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Hostname / IP</label>
|
||||
<input type="text" name="hostname" class="form-control"
|
||||
value="{{ device.hostname }}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Name</label>
|
||||
<input type="text" name="name" class="form-control"
|
||||
value="{{ device.name }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Anzahl Segmente</label>
|
||||
<input type="number" name="segment_count" class="form-control"
|
||||
value="{{ device.segment_count }}" min="1" max="10">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% if not devices %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center text-muted py-4">
|
||||
Noch keine Geraete vorhanden.
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0"><i class="bi bi-plus-circle"></i> Neues Geraet</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" action="/api/devices">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Hostname / IP</label>
|
||||
<input type="text" name="hostname" class="form-control"
|
||||
placeholder="doorlight0027.intra.example.de" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Name (optional)</label>
|
||||
<input type="text" name="name" class="form-control"
|
||||
placeholder="Buero 027">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Anzahl Segmente</label>
|
||||
<input type="number" name="segment_count" class="form-control"
|
||||
value="2" min="1" max="10">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="bi bi-plus"></i> Hinzufuegen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
68
templates/log.html
Normal file
68
templates/log.html
Normal file
@ -0,0 +1,68 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Protokoll - Busylight{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h4 class="mb-0">Protokoll</h4>
|
||||
<span class="text-muted" id="last-update"></span>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover log-table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zeitpunkt</th>
|
||||
<th>Benutzer</th>
|
||||
<th>AIDA-Status</th>
|
||||
<th>Teams-Status</th>
|
||||
<th>WLED-URL</th>
|
||||
<th>Ergebnis</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for log in logs %}
|
||||
<tr>
|
||||
<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>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-info">{{ log.teams_status or '-' }}</span>
|
||||
</td>
|
||||
<td><small class="font-monospace">{{ log.wled_url }}</small></td>
|
||||
<td>
|
||||
{% if log.result and '200' in log.result %}
|
||||
<span class="text-success"><i class="bi bi-check-circle"></i> {{ log.result }}</span>
|
||||
{% else %}
|
||||
<span class="text-danger"><i class="bi bi-x-circle"></i> {{ log.result }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
{% if not logs %}
|
||||
<tr>
|
||||
<td colspan="6" class="text-center text-muted py-4">
|
||||
Noch keine Protokoll-Eintraege vorhanden.
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
setTimeout(() => location.reload(), 15000);
|
||||
document.getElementById('last-update').textContent =
|
||||
'Aktualisiert: ' + new Date().toLocaleTimeString('de-DE');
|
||||
</script>
|
||||
{% endblock %}
|
||||
118
templates/settings.html
Normal file
118
templates/settings.html
Normal file
@ -0,0 +1,118 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Einstellungen - Busylight{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h4 class="mb-4">Einstellungen</h4>
|
||||
|
||||
{% if request.query_params.get('saved') %}
|
||||
<div class="alert alert-success alert-dismissible fade show">
|
||||
<i class="bi bi-check-circle"></i> Einstellungen gespeichert.
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/api/settings">
|
||||
<div class="row g-4">
|
||||
<!-- Azure AD -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0"><i class="bi bi-microsoft"></i> Azure AD / Microsoft Graph</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Client ID</label>
|
||||
<input type="text" name="azure_client_id" class="form-control font-monospace"
|
||||
value="{{ settings.azure_client_id }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Client Secret</label>
|
||||
<input type="password" name="azure_client_secret" class="form-control font-monospace"
|
||||
value="{{ settings.azure_client_secret }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Tenant ID</label>
|
||||
<input type="text" name="azure_tenant_id" class="form-control font-monospace"
|
||||
value="{{ settings.azure_tenant_id }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Auth Tenant</label>
|
||||
<input type="text" name="azure_auth_tenant" class="form-control"
|
||||
value="{{ settings.azure_auth_tenant }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Graph Scopes</label>
|
||||
<input type="text" name="azure_scopes" class="form-control"
|
||||
value="{{ settings.azure_scopes }}">
|
||||
</div>
|
||||
<div class="border rounded p-3 bg-light">
|
||||
{% if authenticated %}
|
||||
<i class="bi bi-check-circle text-success"></i>
|
||||
<strong class="text-success">Authentifiziert</strong>
|
||||
{% else %}
|
||||
<i class="bi bi-x-circle text-danger"></i>
|
||||
<strong class="text-danger">Nicht authentifiziert</strong>
|
||||
<p class="small text-muted mt-2 mb-0">
|
||||
Fuehre auf dem Server aus:<br>
|
||||
<code>python app.py --auth</code>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Polling & AIDA -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0"><i class="bi bi-clock"></i> Polling</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Abfrage-Intervall (Sekunden)</label>
|
||||
<input type="number" name="poll_interval" class="form-control"
|
||||
value="{{ settings.poll_interval }}" min="5" max="300">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0"><i class="bi bi-file-earmark-spreadsheet"></i> AIDA Zeiterfassung</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>
|
||||
</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 }}">
|
||||
</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 }}">
|
||||
</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 }}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-lg"></i> Einstellungen speichern
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
94
templates/users.html
Normal file
94
templates/users.html
Normal file
@ -0,0 +1,94 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Benutzer - Busylight{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h4 class="mb-0">Benutzer</h4>
|
||||
<form method="post" action="/api/users/sync">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-cloud-download"></i> Aus Azure synchronisieren
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if request.query_params.get('synced') %}
|
||||
<div class="alert alert-success alert-dismissible fade show">
|
||||
<i class="bi bi-check-circle"></i> {{ request.query_params.get('synced') }} neue Benutzer synchronisiert.
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if request.query_params.get('error') == 'not_authenticated' %}
|
||||
<div class="alert alert-danger alert-dismissible fade show">
|
||||
<i class="bi bi-x-circle"></i> Azure nicht verbunden. Bitte zuerst authentifizieren.
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>Name</th>
|
||||
<th>E-Mail</th>
|
||||
<th>Personal-Nr (AIDA)</th>
|
||||
<th>Zuordnung</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr class="{% if not user.active %}table-light text-muted{% endif %}">
|
||||
<td>
|
||||
{% if user.active %}
|
||||
<span class="badge bg-success">Aktiv</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Inaktiv</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ user.display_name }}</td>
|
||||
<td><small>{{ user.email or '-' }}</small></td>
|
||||
<td>
|
||||
<form method="post" action="/api/users/{{ user.azure_id }}/update" class="d-flex gap-1">
|
||||
<input type="text" name="personal_nr" value="{{ user.personal_nr or '' }}"
|
||||
class="form-control form-control-sm" style="width: 120px;"
|
||||
placeholder="~~00000~~">
|
||||
<input type="hidden" name="active" value="{{ '1' if user.active else '0' }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-check"></i>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
{% if user.assignment_id %}
|
||||
<span class="badge bg-info"><i class="bi bi-link-45deg"></i> Zugeordnet</span>
|
||||
{% else %}
|
||||
<span class="text-muted">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" action="/api/users/{{ user.azure_id }}/toggle" class="d-inline">
|
||||
<button type="submit" class="btn btn-sm {% if user.active %}btn-outline-warning{% else %}btn-outline-success{% endif %}">
|
||||
{% if user.active %}
|
||||
<i class="bi bi-pause-circle"></i>
|
||||
{% else %}
|
||||
<i class="bi bi-play-circle"></i>
|
||||
{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 text-muted">
|
||||
<small>{{ users|length }} Benutzer insgesamt</small>
|
||||
</div>
|
||||
{% endblock %}
|
||||
26
wled_client.py
Normal file
26
wled_client.py
Normal file
@ -0,0 +1,26 @@
|
||||
import logging
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TIMEOUT = 5
|
||||
|
||||
|
||||
def build_wled_url(hostname, r, g, b, segment=None, effect=""):
|
||||
"""Build WLED HTTP API URL."""
|
||||
base = f"http://{hostname}/win&A=255&R={r}&G={g}&B={b}"
|
||||
if effect:
|
||||
base = f"http://{hostname}{effect}&R={r}&G={g}&B={b}"
|
||||
if segment is not None:
|
||||
base = f"{base}&SS={segment}"
|
||||
return base
|
||||
|
||||
|
||||
def call_wled(url):
|
||||
"""Send HTTP request to WLED device. Returns (success, status_text)."""
|
||||
try:
|
||||
r = requests.post(url, timeout=TIMEOUT)
|
||||
return True, f"{r.status_code} {r.reason}"
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.warning("WLED nicht erreichbar: %s - %s", url, e)
|
||||
return False, str(e)
|
||||
Loading…
x
Reference in New Issue
Block a user