feat: kundenspezifisches Logo unter Einstellungen

Bisher war das Logo fest im Repository verdrahtet. Damit hat ein Update
das Logo eines Kunden ueberschrieben - beim Sprung auf den modularen
Stand zum Beispiel von logo.svg auf die mitgelieferte logo.png.

Unter Einstellungen > Logo laesst sich jetzt ein eigenes Logo hochladen.
Es erscheint in der Seitenleiste, auf der Login-Seite und auf der
Zentrale-Seite, mit Vorschau auf hellem und dunklem Grund.

- Datei liegt unter static/custom/ und ist per .gitignore von der
  Versionierung ausgenommen, ein git pull ueberschreibt sie also nicht
- PNG, JPG, SVG, WEBP, GIF bis 2 MB; Format und Groesse werden geprueft
- es bleibt immer genau eine Datei liegen, alte Endungen werden entfernt
- fehlt die Datei, greift automatisch das mitgelieferte Logo
- Upload und Zuruecksetzen liegen hinter dem Admin-Login
- logo_url() ist als Jinja-Global registriert, damit auch die Seiten ohne
  _ctx() (Login, Zentrale) dasselbe Logo zeigen

.gitignore ergaenzt: static/custom/ sowie die bisher fehlenden
busylight.db-shm, busylight.db-wal und Status.csv - letztere lagen auf
dem Produktivsystem als untracked herum.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-21 08:55:16 +00:00
co-authored by Claude Opus 5
parent b02e6702e4
commit 09d9837dec
8 changed files with 178 additions and 3 deletions
+4
View File
@@ -12,3 +12,7 @@ firmware/
dist/ dist/
build/ build/
.vscode/ .vscode/
static/custom/
busylight.db-shm
busylight.db-wal
Status.csv
+15
View File
@@ -15,6 +15,7 @@ Jedes WLED-Geraet verfuegt ueber zwei Segmente (oben/unten), die jeweils einem B
- **Modulare Zeiterfassung** per CSV oder REST API - kompatibel mit jeder Zeiterfassung - **Modulare Zeiterfassung** per CSV oder REST API - kompatibel mit jeder Zeiterfassung
- **Webbasierte Azure-Authentifizierung** direkt im Browser (kein Terminal noetig) - **Webbasierte Azure-Authentifizierung** direkt im Browser (kein Terminal noetig)
- **Zentrale (Self-Service)** - wechselnde Besetzung des Empfangs-Busylights per Durchwahl, ohne Admin-Passwort - **Zentrale (Self-Service)** - wechselnde Besetzung des Empfangs-Busylights per Durchwahl, ohne Admin-Passwort
- **Eigenes Logo** pro Kunde hochladbar, ueberlebt Updates
- **Einstellungen** fuer Azure, Polling-Intervall und Zeiterfassung - **Einstellungen** fuer Azure, Polling-Intervall und Zeiterfassung
- **Protokoll** der letzten Statusaenderungen - **Protokoll** der letzten Statusaenderungen
@@ -175,6 +176,20 @@ GET/POST http://SERVER:8081/{durchwahl}/callend - Anruf beendet (WLED -> zu
- Waehrend eines aktiven Telefonats ueberschreibt das Teams-Polling die Farbe nicht - Waehrend eines aktiven Telefonats ueberschreibt das Teams-Polling die Farbe nicht
- Die Telefon-Farbe (`phone_active`) ist unter **Farb-Regeln** konfigurierbar - Die Telefon-Farbe (`phone_active`) ist unter **Farb-Regeln** konfigurierbar
## Logo
Unter **Einstellungen > Logo** kann ein kundenspezifisches Logo hochgeladen werden. Es
erscheint in der Seitenleiste, auf der Login-Seite und auf der Zentrale-Seite.
- Formate: PNG, JPG, SVG, WEBP, GIF, maximal 2 MB
- Dargestellt wird eine Hoehe von etwa 40 Pixeln. Die Seitenleiste ist dunkel, ein Logo
mit heller Schrift oder transparentem Hintergrund wirkt dort am besten - die
Einstellungsseite zeigt eine Vorschau auf hellem und dunklem Grund.
- Die Datei liegt unter `static/custom/` und ist per `.gitignore` von der Versionierung
ausgenommen. **Ein `git pull` ueberschreibt das Kundenlogo nicht.**
- Mit **Zurueck zum Standardlogo** wird die Datei entfernt und wieder
`static/logo.png` aus dem Repository verwendet.
## Zentrale (Self-Service) ## Zentrale (Self-Service)
Ein Busylight im Empfangsbereich wird wechselnd besetzt. Damit die Mitarbeiter das Ein Busylight im Empfangsbereich wird wechselnd besetzt. Damit die Mitarbeiter das
+78
View File
@@ -45,6 +45,7 @@ async def lifespan(application: FastAPI):
os.makedirs(BACKUP_DIR, exist_ok=True) os.makedirs(BACKUP_DIR, exist_ok=True)
os.makedirs(WLED_BACKUP_DIR, exist_ok=True) os.makedirs(WLED_BACKUP_DIR, exist_ok=True)
os.makedirs(FIRMWARE_DIR, exist_ok=True) os.makedirs(FIRMWARE_DIR, exist_ok=True)
os.makedirs(CUSTOM_DIR, exist_ok=True)
# Start background polling # Start background polling
interval = int(database.get_setting("poll_interval", "15")) interval = int(database.get_setting("poll_interval", "15"))
@@ -65,6 +66,11 @@ async def lifespan(application: FastAPI):
BACKUP_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "backups") BACKUP_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "backups")
WLED_BACKUP_DIR = os.path.join(BACKUP_DIR, "wled") WLED_BACKUP_DIR = os.path.join(BACKUP_DIR, "wled")
FIRMWARE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "firmware") FIRMWARE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "firmware")
# Kundenspezifische Dateien (Logo). Liegt bewusst ausserhalb der Versionierung,
# damit ein git pull das Kundenlogo nicht ueberschreibt.
CUSTOM_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static", "custom")
LOGO_EXTENSIONS = (".png", ".jpg", ".jpeg", ".svg", ".webp", ".gif")
LOGO_MAX_BYTES = 2 * 1024 * 1024
def auto_backup_db(): def auto_backup_db():
@@ -118,6 +124,27 @@ templates = Jinja2Templates(directory="templates")
SESSION_TOKEN = secrets.token_hex(32) # Regenerated on restart SESSION_TOKEN = secrets.token_hex(32) # Regenerated on restart
DEFAULT_LOGO = "/static/logo.png"
def _logo_url():
"""URL des anzuzeigenden Logos - kundenspezifisch, sonst das mitgelieferte.
Als Jinja-Global registriert, damit auch Seiten ohne _ctx() (Login,
Zentrale) dasselbe Logo zeigen.
"""
name = database.get_setting("logo_file", "")
if name:
# Kein Pfad, nur Dateiname - der Wert kommt aus der eigenen DB, aber
# ein Traversal soll auch dann nicht moeglich sein.
name = os.path.basename(name)
if os.path.exists(os.path.join(CUSTOM_DIR, name)):
return f"/static/custom/{name}"
return DEFAULT_LOGO
templates.env.globals["logo_url"] = _logo_url
def _hash_password(pw): def _hash_password(pw):
return hashlib.sha256(pw.encode()).hexdigest() return hashlib.sha256(pw.encode()).hexdigest()
@@ -984,6 +1011,57 @@ async def update_settings(request: Request):
return RedirectResponse("/settings?saved=1", status_code=303) return RedirectResponse("/settings?saved=1", status_code=303)
@app.post("/api/settings/logo")
async def upload_logo(request: Request, logo: UploadFile = File(...)):
"""Kundenspezifisches Logo hochladen."""
redir = _require_login(request)
if redir: return redir
ext = os.path.splitext(logo.filename or "")[1].lower()
if ext not in LOGO_EXTENSIONS:
return RedirectResponse("/settings?logo_error=format", status_code=303)
content = await logo.read()
if not content:
return RedirectResponse("/settings?logo_error=empty", status_code=303)
if len(content) > LOGO_MAX_BYTES:
return RedirectResponse("/settings?logo_error=size", status_code=303)
os.makedirs(CUSTOM_DIR, exist_ok=True)
# Nur ein Logo behalten, sonst bleiben alte Endungen liegen
for old in os.listdir(CUSTOM_DIR):
if old.startswith("logo."):
try:
os.remove(os.path.join(CUSTOM_DIR, old))
except OSError:
pass
filename = f"logo{ext}"
with open(os.path.join(CUSTOM_DIR, filename), "wb") as f:
f.write(content)
database.set_setting("logo_file", filename)
logger.info("Kundenlogo hochgeladen: %s (%d KB)", filename, len(content) // 1024)
return RedirectResponse("/settings?logo_saved=1", status_code=303)
@app.post("/api/settings/logo/reset")
async def reset_logo(request: Request):
"""Zurueck auf das mitgelieferte Logo."""
redir = _require_login(request)
if redir: return redir
if os.path.isdir(CUSTOM_DIR):
for old in os.listdir(CUSTOM_DIR):
if old.startswith("logo."):
try:
os.remove(os.path.join(CUSTOM_DIR, old))
except OSError:
pass
database.set_setting("logo_file", "")
logger.info("Kundenlogo entfernt, Standardlogo aktiv")
return RedirectResponse("/settings?logo_reset=1", status_code=303)
# ── Log ──────────────────────────────────────────────────────────────────── # ── Log ────────────────────────────────────────────────────────────────────
@app.get("/log", response_class=HTMLResponse) @app.get("/log", response_class=HTMLResponse)
+1
View File
@@ -150,6 +150,7 @@ def init_db():
"attendance_status_homeoffice": "~~2~~", "attendance_status_homeoffice": "~~2~~",
"selfservice_device_id": "", "selfservice_device_id": "",
"selfservice_segment": "0", "selfservice_segment": "0",
"logo_file": "",
} }
for key, value in defaults.items(): for key, value in defaults.items():
conn.execute( conn.execute(
+1 -1
View File
@@ -13,7 +13,7 @@
<div class="d-flex"> <div class="d-flex">
<nav class="sidebar bg-dark text-white d-flex flex-column"> <nav class="sidebar bg-dark text-white d-flex flex-column">
<div class="p-3 border-bottom border-secondary text-center"> <div class="p-3 border-bottom border-secondary text-center">
<a href="/"><img src="/static/logo.png" alt="cynfo" style="height: 32px;"></a> <a href="/"><img src="{{ logo_url() }}" alt="Logo" style="height: 32px;"></a>
</div> </div>
<ul class="nav flex-column mt-2 flex-grow-1"> <ul class="nav flex-column mt-2 flex-grow-1">
<li class="nav-item"> <li class="nav-item">
+1 -1
View File
@@ -14,7 +14,7 @@
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-body p-4"> <div class="card-body p-4">
<div class="text-center mb-4"> <div class="text-center mb-4">
<img src="/static/logo.png" alt="cynfo" style="height: 40px;" class="mb-2"> <img src="{{ logo_url() }}" alt="Logo" style="height: 40px;" class="mb-2">
<h5>Busylight</h5> <h5>Busylight</h5>
</div> </div>
+77
View File
@@ -211,6 +211,83 @@
</button> </button>
</div> </div>
</form> </form>
<!-- Logo: eigener Abschnitt, weil ein Datei-Upload nicht in das obige Formular passt -->
<div class="row g-4 mt-1">
<div class="col-lg-6">
<div class="card">
<div class="card-header">
<h6 class="mb-0"><i class="bi bi-image"></i> Logo</h6>
</div>
<div class="card-body">
{% if request.query_params.get('logo_saved') %}
<div class="alert alert-success py-2"><small><i class="bi bi-check-circle"></i> Logo gespeichert.</small></div>
{% elif request.query_params.get('logo_reset') %}
<div class="alert alert-success py-2"><small><i class="bi bi-check-circle"></i> Standardlogo wiederhergestellt.</small></div>
{% elif request.query_params.get('logo_error') == 'format' %}
<div class="alert alert-danger py-2"><small><i class="bi bi-x-circle"></i> Nicht unterstuetztes Format. Erlaubt: PNG, JPG, SVG, WEBP, GIF.</small></div>
{% elif request.query_params.get('logo_error') == 'size' %}
<div class="alert alert-danger py-2"><small><i class="bi bi-x-circle"></i> Datei ist zu gross (maximal 2 MB).</small></div>
{% elif request.query_params.get('logo_error') == 'empty' %}
<div class="alert alert-danger py-2"><small><i class="bi bi-x-circle"></i> Die Datei war leer.</small></div>
{% endif %}
<div class="mb-3">
<label class="form-label">Aktuelles Logo</label>
<div class="d-flex gap-2">
<div class="border rounded p-3 bg-white text-center flex-fill">
<img src="{{ logo_url() }}" alt="Logo" style="max-height:40px; max-width:100%;">
<div><small class="text-muted">auf hell</small></div>
</div>
<div class="border rounded p-3 bg-dark text-center flex-fill">
<img src="{{ logo_url() }}" alt="Logo" style="max-height:40px; max-width:100%;">
<div><small class="text-secondary">auf dunkel (Seitenleiste)</small></div>
</div>
</div>
<small class="text-muted">
{% if settings.logo_file %}
Kundenlogo aktiv: <code>{{ settings.logo_file }}</code>
{% else %}
Standardlogo aus dem Repository
{% endif %}
</small>
</div>
<form method="post" action="/api/settings/logo" enctype="multipart/form-data" class="mb-2">
<label class="form-label">Eigenes Logo hochladen</label>
<div class="input-group">
<input type="file" name="logo" class="form-control"
accept=".png,.jpg,.jpeg,.svg,.webp,.gif" required>
<button type="submit" class="btn btn-primary">
<i class="bi bi-upload"></i> Hochladen
</button>
</div>
<small class="text-muted">
PNG, JPG, SVG, WEBP oder GIF, maximal 2 MB. Wird in der Seitenleiste, auf der
Login-Seite und auf der Zentrale-Seite angezeigt. Eine Hoehe von etwa 40 Pixeln
wird dargestellt - die Seitenleiste ist dunkel, ein Logo mit heller Schrift oder
transparentem Hintergrund wirkt dort am besten.
</small>
</form>
{% if settings.logo_file %}
<form method="post" action="/api/settings/logo/reset">
<button type="submit" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-counterclockwise"></i> Zurueck zum Standardlogo
</button>
</form>
{% endif %}
<div class="border rounded p-3 bg-light mt-3">
<small class="text-muted">
Das Logo liegt unter <code>static/custom/</code> und ist von der Versionierung
ausgenommen. Ein <code>git pull</code> ueberschreibt es also nicht.
</small>
</div>
</div>
</div>
</div>
</div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
+1 -1
View File
@@ -14,7 +14,7 @@
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-body p-4"> <div class="card-body p-4">
<div class="text-center mb-4"> <div class="text-center mb-4">
<img src="/static/logo.png" alt="cynfo" style="height: 40px;" class="mb-2"> <img src="{{ logo_url() }}" alt="Logo" style="height: 40px;" class="mb-2">
<h5 class="mb-0">Zentrale besetzen</h5> <h5 class="mb-0">Zentrale besetzen</h5>
</div> </div>