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:
@@ -12,3 +12,7 @@ firmware/
|
|||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
static/custom/
|
||||||
|
busylight.db-shm
|
||||||
|
busylight.db-wal
|
||||||
|
Status.csv
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user