Compare commits

...

23 Commits

Author SHA1 Message Date
Erik Thiele
f754c7f0a3 docs: Medien-Cache in AGENTS.md beschreiben
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 12:45:23 +02:00
Erik Thiele
efcbaac5a4 chore: Version auf 6.2.12 heben
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 12:45:16 +02:00
Erik Thiele
96355f6897 feat: Medien im Player mit ?v=<mtime> und langem Cache ausliefern
Bisher lieferte Flask Medien mit no-cache aus; der Player musste jede Datei
bei jeder Anzeige neu anfragen. Versionierte URLs duerfen jetzt ein Jahr
ohne Rueckfrage aus dem Cache kommen, unversionierte bleiben unveraendert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 12:45:16 +02:00
Erik Thiele
2c72f48e28 docs: One-Pager (PDF + HTML-Quelle) fuer Anwender
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 20:08:44 +02:00
Erik Thiele
302811fe29 docs: README und Hilfeseite auf Stand 6.2.11 bringen
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 19:55:53 +02:00
Erik Thiele
a09375cb31 chore: Version auf 6.2.10 heben
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 19:50:29 +02:00
Erik Thiele
ac5bf8f739 feat: 50 Verlaufseintraege je Seite
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 19:50:29 +02:00
Erik Thiele
e3afda9a8f chore: Version auf 6.2.9 heben
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 19:47:35 +02:00
Erik Thiele
20055c3b74 feat: Verlauf im Dashboard seitenweise blaettern
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 19:47:35 +02:00
Erik Thiele
40ee841553 chore: Version auf 6.2.8 heben
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 16:50:08 +02:00
Erik Thiele
3a2ff20815 feat: Willkommensseite ohne Login erreichbar
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 16:50:08 +02:00
Erik Thiele
9d317ee592 chore: Version auf 6.2.7 heben 2026-09-08 09:09:37 +02:00
Erik Thiele
cbadf096fd fix: Loeschen entfernt auch den Playlist-Eintrag der Datei
Der Filter in delete_file verglich nur item["url"], traf damit aber nur
URL-Eintraege. Bei Dateien wurde die Datei vom Datentraeger geloescht,
der Eintrag {"name": ...} blieb als Karteileiche in der config.json
stehen - in der Priority-Playlist wie in jeder Screen-Playlist.
Vergleich laeuft jetzt ueber playlist_item_name(), das alle drei
Eintragsformen abdeckt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 09:09:37 +02:00
Erik Thiele
ccfd90d4b5 chore: Version auf 6.2.6 heben 2026-09-08 09:04:38 +02:00
Erik Thiele
03ae5bf4ad feat: Priority-Eintraege in den Screen-Playlisten anzeigen
Die Priority-Playlist gilt fuer alle Screens eines Standorts, liegt aber
in einem eigenen Menuepunkt und wurde beim Bearbeiten der normalen
Playlisten leicht uebersehen. Sie erscheint jetzt oberhalb jeder
Screen-Playlist als abgesetzte, ausgegraute Liste mit Prio-Badge.

Reine Anzeige: keine Checkbox, keine Buttons, kein Drag. Die Zeilen
liegen bewusst in einer eigenen ul ausserhalb von #playlist-<screen>,
damit savePlaylist() sie nicht mit einsammelt. Sichtbar nur, wenn die
Priority-Playlist des Standorts aktiviert ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 09:04:38 +02:00
Erik Thiele
aeab158e47 fix: Aktiv-Haken zeigt gespeicherten Status statt Zeitfenster
Die Admin-Ansicht hat der Checkbox das Ergebnis von is_item_active()
uebergeben. Ausserhalb des Zeitfensters erschien ein zeitgesteuerter
Eintrag dadurch als deaktiviert, und beim naechsten Speichern der
Reihenfolge schrieb der Client diesen Anzeigezustand als enabled=false
zurueck - der Eintrag war danach dauerhaft stillgelegt.

Die Checkbox spiegelt jetzt das gespeicherte enabled-Flag. Der
Live-Zustand steckt in einem neuen Feld active und faerbt nur noch das
Zeit-Badge (grau, wenn das Fenster gerade zu ist). Der Player filtert
entsprechend ueber beide Felder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 08:47:43 +02:00
Erik Thiele
c76a917a6d chore: Version auf 6.2.4 heben 2026-09-07 10:46:09 +02:00
Erik Thiele
c56006c18d fix: Trusted Device bei MFA zuverlaessig speichern und pruefen
- Dropdown fuer die Merk-Dauer aktiviert jetzt die Checkbox mit; bisher
  wurde die Auswahl ohne gesetzten Haken verworfen und trotzdem nach
  jedem Login ein Code verlangt.
- Abgelaufene oder defekte Trusted-Device-Eintraege fuehrten beim Login
  zu einem NameError (users_data war nicht geladen); Bereinigung neu
  geschrieben.
- Cookie erhaelt das Secure-Flag, wenn die Verbindung HTTPS ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 10:46:09 +02:00
Erik Thiele
ac343cef67 chore: Version auf 6.2.3 heben
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 12:18:22 +02:00
Erik Thiele
af308535fe fix: Standort ueber Seiten ohne Standortbezug hinweg merken
Wer von Stuttgart aus das Admin-Dashboard oeffnete, sah im Header
ploetzlich Karlsruhe - und landete beim Zurueckgehen ueber die
Standortauswahl dort. Grund: Dashboard, Hilfe, User-Verwaltung und die
MFA-Seiten setzten current_site fest auf get_site_list(cfg)[0], also den
global ersten Standort. Der Context-Processor tat dasselbe, fuer
angemeldete User sogar unabhaengig davon, ob sie auf diesen Standort
ueberhaupt zugreifen duerfen.

- remember_site() legt den zuletzt besuchten Standort in der Session ab;
  aufgerufen aus admin(), admin_priority() und add_customer().
- current_site_for() liefert diesen Standort, sonst den ersten
  *zugaenglichen*. Verwendet vom Context-Processor und den genannten
  Seiten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 11:59:58 +02:00
Erik Thiele
f275615f3c chore: config.json nachziehen
Enthaelt die Migration der Priority-Playlist: der globale Schluessel
"priority" ist entfallen, jeder Standort hat seinen eigenen Block.

Ausserdem aus dem laufenden Betrieb: der Standort "test" mit den Screens
"vorstand" und "casino", die Schalter "Willkommensseite hier anzeigen"
sowie Playlist-Eintraege. Der Phantom-Screen "test/logos" - entstanden,
weil der Ordner fuer hochgeladene Logos als Screen interpretiert wurde -
ist entfernt.

Keine neuen Zugangsdaten im Diff.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:46:49 +02:00
Erik Thiele
d2938f87f3 fix: Priority-Playlist je Standort statt global
Die Priority-Playlist lag unter config["priority"] und wirkte damit auf
*alle* Standorte. In der Praxis lief so ein Stuttgart-spezifischer
Eintrag (wbxroompresence...find=Stuttgart) auch auf den Karlsruher
Screens.

Sie liegt jetzt unter sites.<site>.priority, die Medien unter
media/<site>/priority/:
- get_priority_config(cfg, site) liefert die Konfiguration des
  Standorts. Ist dort nichts hinterlegt, wird ein globaler Altbestand
  einmalig uebernommen - per deepcopy, damit spaetere Aenderungen nicht
  zwischen Standorten durchschlagen.
- load_priority_files(site), priority_dir(site) sowie Player, Hash,
  Upload, Loeschen, URL-Anlegen, Umsortieren und Einzeleintrag-
  Bearbeitung arbeiten durchgaengig standortbezogen.
- Route /media/priority/<datei> -> /media/<site>/priority/<datei>,
  Templates entsprechend.
- Der Ordnername "priority" ist wie "logos" in der Screen-Erkennung
  ausgenommen. media/<site>/priority/ waere sonst als Screen in der
  Konfiguration gelandet; "priority" ist in den Routen ohnehin als
  Kennzeichen belegt, ein Screen dieses Namens war nie moeglich.

Migration der vorhandenen Konfiguration: der globale Block wurde in alle
drei Standorte uebernommen und der globale Schluessel entfernt. Der
leere globale Ordner media/priority/ ist entfallen.

README, AGENTS.md und Hilfeseite nachgezogen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:32:27 +02:00
Erik Thiele
a9cbe42df9 docs: Luecken in README und AGENTS.md schliessen
Abgleich der Doku gegen den Code ergab:
- /admin/help und /customer fehlten in der Routen-Tabelle
- Der Verzeichnisbaum zeigte nur die Screen-Ordner, nicht die Dateien
  auf Standort-Ebene (background.*, logo_<slot>.png, welcome.html) -
  gerade die sind aber bewusst Dateien und keine Ordner, weil jeder
  Unterordner als Screen interpretiert wird
- Der Docker-Abschnitt erwaehnte weder das noetige --build noch die
  Log-Einstellungen (PYTHONUNBUFFERED, access-logfile), die fuer jede
  Fehlersuche die Grundlage sind

AGENTS.md bekommt dazu einen Abschnitt "Betrieb / Diagnose".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 18:25:13 +02:00
15 changed files with 1193 additions and 160 deletions

View File

@@ -29,7 +29,7 @@ Standorte (sites) sind die oberste Organisationsebene und gruppieren Screens.
| `GET /admin/<site>` | Admin-Dashboard für einen Standort (`@site_access_required`) | | `GET /admin/<site>` | Admin-Dashboard für einen Standort (`@site_access_required`) |
| `GET /admin/<site>/priority` | Priority-Playlist als separate Seite | | `GET /admin/<site>/priority` | Priority-Playlist als separate Seite |
| `GET /media/<site>/<screen>/<file>` | Medien-Datei ausliefern | | `GET /media/<site>/<screen>/<file>` | Medien-Datei ausliefern |
| `GET /media/priority/<file>` | Priority-Medien (global) | | `GET /media/<site>/priority/<file>` | Priority-Medien des Standorts |
| `GET /media/<site>/background/<filename>` | Hintergrundbild der Willkommensseite | | `GET /media/<site>/background/<filename>` | Hintergrundbild der Willkommensseite |
| `GET /playlist/<site>/<screen>/hash` | Playlist-Checksumme für Auto-Reload + zeichnet Heartbeat auf (Online-Status) | | `GET /playlist/<site>/<screen>/hash` | Playlist-Checksumme für Auto-Reload + zeichnet Heartbeat auf (Online-Status) |
| `GET /willkommen?site=<site>` | Willkommensseite für Standort generieren | | `GET /willkommen?site=<site>` | Willkommensseite für Standort generieren |
@@ -109,10 +109,11 @@ Standorte (sites) sind die oberste Organisationsebene und gruppieren Screens.
- `GET /admin` requires login; redirects to first accessible site for the user. - `GET /admin` requires login; redirects to first accessible site for the user.
- URL playlist items are stored as dicts like `{"url": "https://...", "zoom": 0.8}` and the zoom value must survive save/reorder flows. File items may also be dicts (`{"name": "bild.jpg", ...}`); `save_playlist` copies existing dicts, so extra keys like `schedule` survive reordering. - URL playlist items are stored as dicts like `{"url": "https://...", "zoom": 0.8}` and the zoom value must survive save/reorder flows. File items may also be dicts (`{"name": "bild.jpg", ...}`); `save_playlist` copies existing dicts, so extra keys like `schedule` survive reordering.
- `.html` items in `media/` are rendered inline as content, not in an iframe. - `.html` items in `media/` are rendered inline as content, not in an iframe.
- `config.priority.enabled` makes the priority playlist show on every screen. - `sites.<site>.priority.enabled` makes the priority playlist show on every screen **of that site**.
- `POST /api/customer` generates `welcome.html` and inserts it at the front of the playlist of every target screen for the specified site. Target screens are those with `welcome_enabled`; if no screen of the site has the key at all, the old behaviour applies (`lobby` only). Optional `valid_until` (`"YYYY-MM-DD"`) attaches a schedule so the page disappears on its own. - `POST /api/customer` generates `welcome.html` and inserts it at the front of the playlist of every target screen for the specified site. Target screens are those with `welcome_enabled`; if no screen of the site has the key at all, the old behaviour applies (`lobby` only). Optional `valid_until` (`"YYYY-MM-DD"`) attaches a schedule so the page disappears on its own.
- New standorte can be added by creating `media/<neuer-standort>/<screen>/` directories and optionally adding config to `config.json["sites"][<neuer-standort>]`. - New standorte can be added by creating `media/<neuer-standort>/<screen>/` directories and optionally adding config to `config.json["sites"][<neuer-standort>]`.
- Priority playlist (`config.priority`) is global and affects all sites/screens. - **Die Priority-Playlist gilt je Standort** (`sites.<site>.priority`), nicht global. Frueher lag sie unter `config.priority` und wirkte auf alle Standorte - dadurch lief Stuttgarter Inhalt auch in Karlsruhe. `get_priority_config(cfg, site)` liefert die Konfiguration des Standorts und uebernimmt einen globalen Altbestand beim ersten Zugriff einmalig (mit `deepcopy`, damit Aenderungen nicht zwischen Standorten durchschlagen).
- Priority-Medien liegen unter `media/<site>/priority/`, ausgeliefert ueber `/media/<site>/priority/<datei>`. Die Ordnernamen `priority` und `logos` sind in der Screen-Erkennung ausgenommen - sonst wuerden sie als Screens in der Konfiguration landen.
- Willkommensseite (`customer.html`) accepts up to 3 customer names; logos are fetched via OpenAI→Brandfetch and displayed in a flex row (portrait: column). - Willkommensseite (`customer.html`) accepts up to 3 customer names; logos are fetched via OpenAI→Brandfetch and displayed in a flex row (portrait: column).
- `generate_welcome_html(customer_names, logo_urls, contacts=None, site="stuttgart", background_url=None)` takes lists for up to 3 customers; logos have equal width (280px) with fixed height 120px, text left-aligned below each logo. - `generate_welcome_html(customer_names, logo_urls, contacts=None, site="stuttgart", background_url=None)` takes lists for up to 3 customers; logos have equal width (280px) with fixed height 120px, text left-aligned below each logo.
- Contact info supports Markdown: `**bold**`, `*italic*`, `` `code` ``, `- list items`, and line breaks (preserved as `<br>`). - Contact info supports Markdown: `**bold**`, `*italic*`, `` `code` ``, `- list items`, and line breaks (preserved as `<br>`).
@@ -153,13 +154,22 @@ Standorte (sites) sind die oberste Organisationsebene und gruppieren Screens.
- User-Dropdown im Header: zeigt E-Mail + Role-Badge, Menü mit Passwort ändern, MFA einrichten (mit "Aktiv"-Badge wenn MFA eingeschaltet), Userverwaltung (Admin), Abmelden. - User-Dropdown im Header: zeigt E-Mail + Role-Badge, Menü mit Passwort ändern, MFA einrichten (mit "Aktiv"-Badge wenn MFA eingeschaltet), Userverwaltung (Admin), Abmelden.
- Info-Tab in Screen-Cards: zeigt Client-Info-Tabelle (IP, Browser, Auflösung, zuletzt gesehen) aus dem Heartbeat-JSON für den jeweiligen Screen - Info-Tab in Screen-Cards: zeigt Client-Info-Tabelle (IP, Browser, Auflösung, zuletzt gesehen) aus dem Heartbeat-JSON für den jeweiligen Screen
- **Medien-Cache**: `player()` gibt jedem Datei-Eintrag `v` (mtime) mit, der Player haengt `?v=<mtime>` an. `send_media()` setzt dann `max-age` 1 Jahr + `immutable`; ohne `?v` bleibt Flasks `no-cache`. Eine ersetzte Datei bekommt so automatisch eine neue URL. Grosse Videos cacht der Browser trotzdem oft nicht (eigener Media-Cache) - dafuer braeuchte es einen Service Worker.
## Betrieb / Diagnose
- `docker compose up -d` startet nur neu und **baut nicht**. Nach Code-Aenderungen `--build` verwenden, sonst laeuft weiter das alte Image - das hat bei der Fehlersuche mehrfach zu falschen Schluessen gefuehrt.
- Das Dockerfile setzt `PYTHONUNBUFFERED=1`. Ohne das puffert Python `stdout` blockweise (kein TTY) und **keine** `print()`-Ausgabe der App erscheint in `docker compose logs`. Tracebacks waren sichtbar, weil Flask sie ueber stderr schreibt - das machte den blinden Fleck schwer erkennbar.
- gunicorn laeuft mit `--access-logfile -`, protokolliert also jeden Request. Ohne das laesst sich nicht feststellen, ob eine Anfrage den Container ueberhaupt erreicht.
- `add_customer` protokolliert je Kundenfeld `Upload = <dateiname>` bzw. `Upload = keiner`. Ein geleertes `<input type="file">` - etwa nach einem Reload der Seite - sieht serverseitig sonst genauso aus wie "kein Logo hochgeladen".
## Repo Quirks ## Repo Quirks
- `media/` and media file extensions are gitignored. - `media/` and media file extensions are gitignored.
- `users.json` is NOT gitignored (tracked for initial admin setup, contains no secrets by default). - `users.json` is NOT gitignored (tracked for initial admin setup, contains no secrets by default).
- The app has no configured tests, lint, typecheck, formatter, or CI. - The app has no configured tests, lint, typecheck, formatter, or CI.
- Hardcoded secrets exist in tracked files; do not commit new secrets or reshuffle them casually. - Hardcoded secrets exist in tracked files; do not commit new secrets or reshuffle them casually.
- Wichtige Helper-Funktionen in `app.py`: `load_config()`, `save_config()`, `get_site_list()`, `get_screen_config()`, `is_url()`, `normalize_url()`, `playlist_item_name()`, `is_item_active()`, `_schedule_time()`, `_window_start_date()`, `_in_validity_range()`, `load_priority_files()`, `prio_redirect()`, `get_background_url()`, `load_users()`, `save_users()`, `get_user()`, `init_user_db()`, `get_accessible_sites()`, `record_heartbeat()`, `screen_is_active()`, `get_client_info()`, `add_history_entry()`. - Wichtige Helper-Funktionen in `app.py`: `load_config()`, `save_config()`, `get_site_list()`, `get_screen_config()`, `is_url()`, `normalize_url()`, `playlist_item_name()`, `is_item_active()`, `_schedule_time()`, `_window_start_date()`, `_in_validity_range()`, `load_priority_files(site)`, `get_priority_config(cfg, site)`, `priority_dir(site)`, `prio_redirect()`, `get_background_url()`, `load_users()`, `save_users()`, `get_user()`, `init_user_db()`, `get_accessible_sites()`, `record_heartbeat()`, `screen_is_active()`, `get_client_info()`, `add_history_entry()`.
- Zugriffs-Dekoratoren in `app.py`: `admin_required`, `site_access_required`. - Zugriffs-Dekoratoren in `app.py`: `admin_required`, `site_access_required`.
- Hintergrundbild der Willkommensseite wird pro Standort unter `media/<site>/background.*` gespeichert; Fallback auf `static/wallpaper.png` wenn keine Datei existiert. - Hintergrundbild der Willkommensseite wird pro Standort unter `media/<site>/background.*` gespeichert; Fallback auf `static/wallpaper.png` wenn keine Datei existiert.
- `get_background_url(site)` prüft auf benutzerdefiniertes Hintergrundbild für einen Standort. - `get_background_url(site)` prüft auf benutzerdefiniertes Hintergrundbild für einen Standort.

View File

@@ -9,8 +9,8 @@ Browserbasiertes Digital-Signage-System für interne Info-Screens.
- Browserbasierter Player (Kiosk-Modus, Chromium, Firefox) - Browserbasierter Player (Kiosk-Modus, Chromium, Firefox)
- Bilder, Videos (MP4/H.264), HTML-Seiten, URL-Playlist-Einträge - Bilder, Videos (MP4/H.264), HTML-Seiten, URL-Playlist-Einträge
- **Multi-Standort**: Standorte (sites) gruppieren Screens - **Multi-Standort**: Standorte (sites) gruppieren Screens
- **Priority-Playlist**: globale Inhalte wirken auf alle Player - **Priority-Playlist**: pro Standort; die Inhalte laufen auf allen Screens **dieses** Standorts, nicht standortübergreifend. Sie werden zusätzlich als **reine Vorschau** oberhalb jeder Screen-Playlist angezeigt (grau, mit „Prio"-Badge), damit sie beim Bearbeiten nicht übersehen werden – bedient wird weiterhin nur auf der Priority-Seite
- **Willkommensseite**: bis zu 3 Kundenlogos via OpenAI + Brandfetch oder **manueller Upload mit automatischem Freistellen** (transparenter Hintergrund), pro Standort konfigurierbares Hintergrundbild; erscheint auf allen Screens mit `welcome_enabled` (mehrere möglich), optional mit Ablaufdatum - **Willkommensseite** (ohne Login erreichbar): bis zu 3 Kundenlogos via OpenAI + Brandfetch oder **manueller Upload mit automatischem Freistellen** (transparenter Hintergrund), pro Standort konfigurierbares Hintergrundbild; erscheint auf allen Screens mit `welcome_enabled` (mehrere möglich), optional mit Ablaufdatum
- Pro Screen eigene Playlist mit Drag-&-Drop-Reihenfolge - Pro Screen eigene Playlist mit Drag-&-Drop-Reihenfolge
- Auto-Reload bei Playlist-Änderungen - Auto-Reload bei Playlist-Änderungen
- Newsticker pro Screen - Newsticker pro Screen
@@ -22,8 +22,8 @@ Browserbasiertes Digital-Signage-System für interne Info-Screens.
- **User-Verwaltung**: Mehrere User mit Rollen (Admin / Superuser / User), E-Mail als Login, Passwort-Hashing (scrypt), Berechtigungen pro Standort - **User-Verwaltung**: Mehrere User mit Rollen (Admin / Superuser / User), E-Mail als Login, Passwort-Hashing (scrypt), Berechtigungen pro Standort
- **Passwort-Workflow**: First-Login-Änderung, Admin-Reset mit temporärem Passwort, Notfall-Login bei fehlendem Hash - **Passwort-Workflow**: First-Login-Änderung, Admin-Reset mit temporärem Passwort, Notfall-Login bei fehlendem Hash
- **MFA (Multi-Faktor)**: Optionale TOTP-Authentifizierung (Google Authenticator, Authy) per User aktivierbar; QR-Code-Scan + Code-Verifikation, 8 Recovery-Codes, Deaktivierung mit Passwort-Bestätigung, **Trusted Device** (Gerät für 1–30 Tage speichern, überspringt MFA beim nächsten Login) - **MFA (Multi-Faktor)**: Optionale TOTP-Authentifizierung (Google Authenticator, Authy) per User aktivierbar; QR-Code-Scan + Code-Verifikation, 8 Recovery-Codes, Deaktivierung mit Passwort-Bestätigung, **Trusted Device** (Gerät für 1–30 Tage speichern, überspringt MFA beim nächsten Login)
- **Admin Dashboard**: `/admin/dashboard` – Statistiken (Sites, Screens, User, Admins, Superuser) mit Sparkline-Charts + Trendanzeige, Aktivitätsverlauf - **Admin Dashboard**: `/admin/dashboard` – Statistiken (Sites, Screens, User, Admins, Superuser) mit Sparkline-Charts + Trendanzeige, Aktivitätsverlauf mit Seitennavigation
- **Aktivitätsverlauf**: Alle Erstell-/Lösch-/Änderungsaktionen sowie Login/Logout werden in `history.json` protokolliert und im Dashboard angezeigt - **Aktivitätsverlauf**: Alle Erstell-/Lösch-/Änderungsaktionen sowie Login/Logout werden in `history.json` protokolliert (bis zu 1000 Einträge) und im Dashboard seitenweise angezeigt (50 je Seite)
- Tab-basierte Admin-UI pro Screen: Playlist, Einstellungen, Aktionen, Digital Voice Agent, Medien, Info (Tabler Tabs) - Tab-basierte Admin-UI pro Screen: Playlist, Einstellungen, Aktionen, Digital Voice Agent, Medien, Info (Tabler Tabs)
- **Zeitsteuerung**: Pro Playlist-Eintrag (URL *und* Datei, auch in der Priority-Playlist) ein Zeitfenster festlegen – einmalig (Datum + Uhrzeit), täglich oder wöchentlich (Uhrzeit + optionaler Gültigkeitszeitraum, wöchentlich zusätzlich mit Wochentagen). Fenster über Mitternacht werden unterstützt. Ein "Zeit"-Badge in der Playlist-Zeile zeigt die Einstellung im Tooltip - **Zeitsteuerung**: Pro Playlist-Eintrag (URL *und* Datei, auch in der Priority-Playlist) ein Zeitfenster festlegen – einmalig (Datum + Uhrzeit), täglich oder wöchentlich (Uhrzeit + optionaler Gültigkeitszeitraum, wöchentlich zusätzlich mit Wochentagen). Fenster über Mitternacht werden unterstützt. Ein "Zeit"-Badge in der Playlist-Zeile zeigt die Einstellung im Tooltip
- **Query-Parameter-Passthrough**: Per Player-URL übergebene Query-Parameter (z. B. `/player/stuttgart/lobby?customer=acme`) können pro Playlist-URL im Edit-Modal aktiviert werden – standardmäßig aus. Link-Icon in der Playlist-Zeile zeigt Status (an/aus) an. - **Query-Parameter-Passthrough**: Per Player-URL übergebene Query-Parameter (z. B. `/player/stuttgart/lobby?customer=acme`) können pro Playlist-URL im Edit-Modal aktiviert werden – standardmäßig aus. Link-Icon in der Playlist-Zeile zeigt Status (an/aus) an.
@@ -53,7 +53,7 @@ Flask App (Server)
│ │ │ ├── bild.jpg │ │ │ ├── bild.jpg
│ │ │ ├── video.mp4 │ │ │ ├── video.mp4
│ │ │ └── welcome.html │ │ │ └── welcome.html
│ └── priority/ │ │ └── priority/
└── generate_welcome_page.py └── generate_welcome_page.py
``` ```
@@ -75,10 +75,13 @@ signage/
├── history.json # Aktivitätsverlauf (Dashboard) ├── history.json # Aktivitätsverlauf (Dashboard)
├── media/ ├── media/
│ ├── <site>/ │ ├── <site>/
│ │ ├── lobby/ │ │ ├── lobby/ # Screen-Ordner (jeder Unterordner = ein Screen!)
│ │ ├── casino/ │ │ ├── casino/
│ │ └── videosysteme/ │ │ ├── videosysteme/
│ └── priority/ │ │ ├── background.jpg # Hintergrundbild der Willkommensseite (optional)
│ │ ├── priority/ # Priority-Medien dieses Standorts
│ │ ├── logo_1.png # hochgeladene Kundenlogos (Slot 1-3)
│ │ └── welcome.html # Standort-Kopie der Willkommensseite
├── templates/ ├── templates/
│ ├── admin.html # Admin-Dashboard (Übersicht) │ ├── admin.html # Admin-Dashboard (Übersicht)
│ ├── admin_dashboard.html # Admin Dashboard (Statistiken + Verlauf) │ ├── admin_dashboard.html # Admin Dashboard (Statistiken + Verlauf)
@@ -175,6 +178,25 @@ docker compose up -d
Server läuft auf `http://localhost:5005` (gunicorn mit 4 Workern, non-root User). Server läuft auf `http://localhost:5005` (gunicorn mit 4 Workern, non-root User).
**Wichtig:** `docker compose up -d` startet nur neu, es baut nicht. Nach Code-Änderungen:
```bash
docker compose up -d --build
```
### Logs
```bash
docker compose logs -f signage
```
Der Container ist so eingestellt, dass dort alles Nötige ankommt:
- `ENV PYTHONUNBUFFERED=1` im Dockerfile – ohne das puffert Python `stdout` blockweise, sobald es kein Terminal ist, und sämtliche `print()`-Ausgaben der App bleiben unsichtbar.
- `gunicorn --access-logfile -` – protokolliert jeden Request. Ohne das ist nicht einmal erkennbar, ob eine Anfrage den Container überhaupt erreicht.
Beides fehlte anfangs und machte die Fehlersuche unnötig schwer.
--- ---
## Routen ## Routen
@@ -185,7 +207,7 @@ Server läuft auf `http://localhost:5005` (gunicorn mit 4 Workern, non-root User
| `GET /login` | Admin-Login | | `GET /login` | Admin-Login |
| `GET /logout` | Ausloggen | | `GET /logout` | Ausloggen |
| `GET /admin` | Redirect zum ersten konfigurierten Standort | | `GET /admin` | Redirect zum ersten konfigurierten Standort |
| `GET /admin/dashboard` | Admin Dashboard (Statistiken + Verlauf) | | `GET /admin/dashboard` | Admin Dashboard (Statistiken + Verlauf, `?page=<n>`) |
| `GET /admin/<site>` | Admin-Dashboard für einen Standort | | `GET /admin/<site>` | Admin-Dashboard für einen Standort |
| `GET /admin/<site>/priority` | Priority-Playlist (separate Seite) | | `GET /admin/<site>/priority` | Priority-Playlist (separate Seite) |
| `POST /admin/<site>/update/<screen>` | Allgemeine Screen-Einstellungen speichern (Intervall, Newsticker, Bilder/Videos, stay_on_first) | | `POST /admin/<site>/update/<screen>` | Allgemeine Screen-Einstellungen speichern (Intervall, Newsticker, Bilder/Videos, stay_on_first) |
@@ -204,7 +226,7 @@ Server läuft auf `http://localhost:5005` (gunicorn mit 4 Workern, non-root User
| `POST /admin/<site>/delete-background` | Hintergrundbild zurücksetzen | | `POST /admin/<site>/delete-background` | Hintergrundbild zurücksetzen |
| `GET /player/<site>/<screen>` | Player-Ansicht | | `GET /player/<site>/<screen>` | Player-Ansicht |
| `GET /playlist/<site>/<screen>/hash` | Playlist-Checksumme (für Auto-Reload) + Heartbeat (Online-Status) | | `GET /playlist/<site>/<screen>/hash` | Playlist-Checksumme (für Auto-Reload) + Heartbeat (Online-Status) |
| `GET /willkommen?site=<site>` | Willkommensseite-Formular (GET + POST) | | `GET /willkommen?site=<site>` | Willkommensseite-Formular (GET + POST) – **ohne Login** |
| `POST /api/customer` | API-Endpunkt für Willkommensseite (JSON) | | `POST /api/customer` | API-Endpunkt für Willkommensseite (JSON) |
| `GET /change-password` | Passwort ändern (First-Login / nach Admin-Reset) | | `GET /change-password` | Passwort ändern (First-Login / nach Admin-Reset) |
| `GET /admin/users` | User-Liste (Admin) | | `GET /admin/users` | User-Liste (Admin) |
@@ -220,8 +242,10 @@ Server läuft auf `http://localhost:5005` (gunicorn mit 4 Workern, non-root User
| `POST /admin/users/reset-password/<email>` | Passwort-Reset mit temporärem Passwort (Admin) | | `POST /admin/users/reset-password/<email>` | Passwort-Reset mit temporärem Passwort (Admin) |
| `POST /admin/users/edit/<email>` | User bearbeiten speichern (Admin) | | `POST /admin/users/edit/<email>` | User bearbeiten speichern (Admin) |
| `GET /media/<site>/<screen>/<file>` | Medien-Datei ausliefern | | `GET /media/<site>/<screen>/<file>` | Medien-Datei ausliefern |
| `GET /media/priority/<file>` | Priority-Medien (global) | | `GET /media/<site>/priority/<file>` | Priority-Medien des Standorts |
| `GET /media/<site>/logos/<file>` | Manuell hochgeladenes Kundenlogo | | `GET /media/<site>/logos/<file>` | Manuell hochgeladenes Kundenlogo |
| `GET /admin/help` | Hilfeseite zur Bedienung |
| `GET /customer` | Alias für `/willkommen` |
| `GET /media/<site>/background/<filename>` | Hintergrundbild der Willkommensseite | | `GET /media/<site>/background/<filename>` | Hintergrundbild der Willkommensseite |
### Heartbeat / Online-Status ### Heartbeat / Online-Status
@@ -263,6 +287,8 @@ http://localhost:5005/admin/dashboard
- 5 Statistik-Cards (Standorte, Screens, User, Admins, Superuser) mit ApexCharts-Sparkline + Trendanzeige - 5 Statistik-Cards (Standorte, Screens, User, Admins, Superuser) mit ApexCharts-Sparkline + Trendanzeige
- Aktivitätsverlauf (History) als Liste mit Zeitstempel, Aktion und User - Aktivitätsverlauf (History) als Liste mit Zeitstempel, Aktion und User
- Alle Erstell-/Lösch-/Änderungsaktionen sowie Login/Logout werden protokolliert - Alle Erstell-/Lösch-/Änderungsaktionen sowie Login/Logout werden protokolliert
- **Seitennavigation**: 50 Einträge je Seite (`HISTORY_PER_PAGE`), Aufruf über `?page=<n>`; aufbewahrt werden 1000 Einträge (`HISTORY_MAX`)
- Kennzahlen und Sparklines beziehen sich immer auf die **jüngsten 30 Aktionen** und ändern sich beim Blättern bewusst nicht – sonst würden die Trendpfeile springen, obwohl sich nichts geändert hat
### Player-URL ### Player-URL
@@ -292,6 +318,8 @@ Beispiel:
http://localhost:5005/willkommen?site=stuttgart http://localhost:5005/willkommen?site=stuttgart
``` ```
**Ohne Login erreichbar:** Die Seite ist bewusst nicht durch `@login_required` geschützt, damit auch Mitarbeitende ohne Benutzerkonto eine Begrüßung erstellen können. Ein angemeldeter User sieht dabei weiterhin nur seine freigegebenen Standorte; ohne Anmeldung stehen alle Standorte zur Auswahl, ein unbekannter Standort führt zu 403. Im Aktivitätsverlauf erscheinen solche Einträge als „nicht angemeldet". Die programmatische Variante `POST /api/customer` bleibt angemeldet.
Maximal 3 Kunden eingeben → Logos werden via OpenAI + Brandfetch gesucht → `welcome.html` wird in den Playlists aller Zielscreens vorne eingefügt. Pro Standort kann ein eigenes Hintergrundbild hochgeladen werden (Fallback auf `static/wallpaper.png`). Maximal 3 Kunden eingeben → Logos werden via OpenAI + Brandfetch gesucht → `welcome.html` wird in den Playlists aller Zielscreens vorne eingefügt. Pro Standort kann ein eigenes Hintergrundbild hochgeladen werden (Fallback auf `static/wallpaper.png`).
**Zielscreens:** Jeder Screen mit `welcome_enabled` (Schalter „Willkommensseite hier anzeigen" im Tab Einstellungen). Besitzt kein Screen des Standorts den Schlüssel, greift das alte Verhalten und die Seite geht nur in `lobby`. **Zielscreens:** Jeder Screen mit `welcome_enabled` (Schalter „Willkommensseite hier anzeigen" im Tab Einstellungen). Besitzt kein Screen des Standorts den Schlüssel, greift das alte Verhalten und die Seite geht nur in `lobby`.
@@ -403,10 +431,6 @@ OPENAI_API_KEY=sk-...
"background_url": null "background_url": null
} }
} }
},
"priority": {
"enabled": true,
"playlist": [...]
} }
} }
``` ```
@@ -436,6 +460,32 @@ OPENAI_API_KEY=sk-...
| `voice_agent_target` | string | `"overlay"` (iframe) oder `"redirect"` | | `voice_agent_target` | string | `"overlay"` (iframe) oder `"redirect"` |
| `voice_agent_position` | string | Position des Buttons: `top-left`, `top-center`, `top-right`, `middle-left`, `middle-center`, `middle-right`, `bottom-left`, `bottom-center`, `bottom-right` | | `voice_agent_position` | string | Position des Buttons: `top-left`, `top-center`, `top-right`, `middle-left`, `middle-center`, `middle-right`, `bottom-left`, `bottom-center`, `bottom-right` |
## Priority-Playlist (pro Standort)
Liegt in `config.json` unter `sites.<site>.priority`, die Medien unter `media/<site>/priority/`:
```jsonc
"sites": {
"stuttgart": {
"priority": {
"enabled": true, // schaltet die Priority-Playlist dieses Standorts
"playlist": [ ... ] // gleiches Eintragsformat wie eine Screen-Playlist
},
"screens": { ... }
}
}
```
Die Einträge laufen auf allen Screens **dieses** Standorts, eingestreut zwischen den normalen Playlist-Einträgen. Ein anderer Standort ist davon nicht betroffen.
**Vorschau in den Screen-Playlisten:** Ist Priority für den Standort aktiviert, erscheinen die Einträge zusätzlich über jeder Screen-Playlist – ausgegraut, mit rotem „Prio"-Badge und ohne Bedienelemente. Sie liegen bewusst in einer **eigenen `<ul>`**, damit das Speichern der Reihenfolge (`savePlaylist()`) sie nicht mit einsammelt und in die Screen-Playlist schreibt.
> Früher lag die Priority-Playlist global unter `config.priority` und wirkte auf *alle* Standorte. `get_priority_config()` übernimmt einen solchen Altbestand beim ersten Zugriff einmalig in den jeweiligen Standort.
Die Ordnernamen `priority` und `logos` sind unter `media/<site>/` **reserviert** und werden bei der Screen-Erkennung übersprungen – sonst würden sie als Screens in der Oberfläche auftauchen.
---
<a id="playlist-eintraege"></a> <a id="playlist-eintraege"></a>
## Playlist-Einträge ## Playlist-Einträge
@@ -446,7 +496,7 @@ Ein Eintrag ist entweder ein String (Dateiname oder URL, Altbestand) oder ein Di
{ {
"url": "https://example.com", "url": "https://example.com",
"zoom": 1.0, // Zoom-Faktor im Player "zoom": 1.0, // Zoom-Faktor im Player
"enabled": true, // Checkbox in der Playlist "enabled": true, // Checkbox in der Playlist (nur der Schalter, nicht das Zeitfenster)
"passthrough_params": false, // Query-Parameter der Player-URL durchreichen "passthrough_params": false, // Query-Parameter der Player-URL durchreichen
"schedule": { ... } // optional, siehe unten "schedule": { ... } // optional, siehe unten
} }
@@ -491,6 +541,9 @@ Optional pro Eintrag. Ohne `schedule` läuft der Eintrag immer (sofern `enabled`
- Unlesbare oder unvollständige Zeitangaben blenden den Eintrag **nicht** aus – ein Konfigurationsfehler soll nicht dazu führen, dass stillschweigend nichts mehr läuft. - Unlesbare oder unvollständige Zeitangaben blenden den Eintrag **nicht** aus – ein Konfigurationsfehler soll nicht dazu führen, dass stillschweigend nichts mehr läuft.
- `_schedule_time()` liest neben `"08:00"` auch volle ISO-Zeitstempel, damit Einträge aus älteren Versionen unverändert weiterlaufen. - `_schedule_time()` liest neben `"08:00"` auch volle ISO-Zeitstempel, damit Einträge aus älteren Versionen unverändert weiterlaufen.
- Der Aktiv-Zustand fliesst in `/playlist/<site>/<screen>/hash` ein, damit der Player beim Öffnen und Schliessen eines Zeitfensters von selbst neu lädt. - Der Aktiv-Zustand fliesst in `/playlist/<site>/<screen>/hash` ein, damit der Player beim Öffnen und Schliessen eines Zeitfensters von selbst neu lädt.
- **Checkbox und Zeitfenster sind getrennt:** Die Checkbox in der Playlist-Zeile zeigt ausschliesslich den gespeicherten Schalter `enabled`, nicht den momentanen Zeitfenster-Zustand. Sie bleibt also gesetzt, auch wenn ein zeitgesteuerter Eintrag gerade nicht läuft. Ob er gerade läuft, sagt das „Zeit"-Badge: blau = läuft, grau = ausserhalb des Fensters (Tooltip nennt den Grund). Abgespielt wird nur, was **beides** erfüllt – Haken gesetzt *und* im Zeitfenster.
> Bis Version 6.2.4 zeigte die Checkbox den ausgewerteten Zustand. Wurde die Playlist ausserhalb des Zeitfensters gespeichert, schrieb die Oberfläche `enabled: false` in die Konfiguration und der Eintrag war dauerhaft abgeschaltet. Der Fehler ist behoben, wirkt aber nicht rückwirkend: bereits betroffene Einträge müssen einmal von Hand wieder aktiviert werden.
--- ---
@@ -507,7 +560,10 @@ Optional pro Eintrag. Ohne `schedule` läuft der Eintrag immer (sofern `enabled`
- `_window_start_date(now, start, end)` – Kalendertag, an dem das laufende Zeitfenster begann (`None` = ausserhalb) - `_window_start_date(now, start, end)` – Kalendertag, an dem das laufende Zeitfenster begann (`None` = ausserhalb)
- `_in_validity_range(schedule, day)` – optionalen Gültigkeitszeitraum prüfen - `_in_validity_range(schedule, day)` – optionalen Gültigkeitszeitraum prüfen
- `schedule_text(schedule)` – Jinja-Filter: Klartext einer Zeitsteuerung für Tooltips - `schedule_text(schedule)` – Jinja-Filter: Klartext einer Zeitsteuerung für Tooltips
- `load_priority_files()` – Priority-Playlist + Dateien laden - `load_priority_files(site)` – Priority-Playlist eines Standorts + Dateien laden
- `_load_history()` / `get_history(limit, offset)` / `get_history_count()` – Aktivitätsverlauf lesen, seitenweise ausschneiden, zählen
- `get_priority_config(cfg, site)` – Priority-Konfiguration des Standorts (übernimmt einmalig einen globalen Altbestand)
- `priority_dir(site)` – Pfad zu `media/<site>/priority/`
- `welcome_target_screens(config, site)` – Zielscreens der Willkommensseite (Fallback `lobby`) - `welcome_target_screens(config, site)` – Zielscreens der Willkommensseite (Fallback `lobby`)
- `save_uploaded_logo(file, site, slot, freistellen=True)` – hochgeladenes Logo freistellen und als PNG ablegen - `save_uploaded_logo(file, site, slot, freistellen=True)` – hochgeladenes Logo freistellen und als PNG ablegen
- `remove_logo_background(image)` – Hintergrund per Flood Fill entfernen, gibt `(bild, hinweis)` zurück - `remove_logo_background(image)` – Hintergrund per Flood Fill entfernen, gibt `(bild, hinweis)` zurück
@@ -535,6 +591,7 @@ Wird über `localStorage("signage-theme")` persistiert. Umschalt-Button im Heade
## Sicherheit ## Sicherheit
- Admin-Bereich per Flask-Login geschützt (E-Mail + Passwort) - Admin-Bereich per Flask-Login geschützt (E-Mail + Passwort)
- **Ohne Login erreichbar** sind: Player und Medien (die Abspielgeräte melden sich nicht an) sowie das Formular der Willkommensseite. Letzteres legt Dateien an, schreibt in die Konfiguration und löst kostenpflichtige Logo-Abfragen aus – bei einer aus dem Internet erreichbaren Instanz empfiehlt sich eine Einschränkung auf das interne Netz im Reverse Proxy
- Optionale MFA (TOTP) per User aktivierbar (Google Authenticator, Authy) - Optionale MFA (TOTP) per User aktivierbar (Google Authenticator, Authy)
- 8 Recovery-Codes pro User, einmalig bei Aktivierung angezeigt - 8 Recovery-Codes pro User, einmalig bei Aktivierung angezeigt
- Passwörter gehasht (scrypt) in `users.json` - Passwörter gehasht (scrypt) in `users.json`

290
app.py
View File

@@ -10,6 +10,7 @@ zugeschaltet werden, die auf allen Playern abwechselnd eingeblendet wird.
import os import os
import json import json
import copy
import hashlib import hashlib
import re import re
import time import time
@@ -49,14 +50,37 @@ MEDIA_DIR = os.path.join(BASE_DIR, "media") # Medien-Dateien je Standort/S
CONFIG_FILE = os.path.join(BASE_DIR, "config.json") # Persistente Konfiguration CONFIG_FILE = os.path.join(BASE_DIR, "config.json") # Persistente Konfiguration
USERS_FILE = os.path.join(BASE_DIR, "users.json") # User-Datenbank USERS_FILE = os.path.join(BASE_DIR, "users.json") # User-Datenbank
HISTORY_FILE = os.path.join(BASE_DIR, "history.json") # Aktivitätsverlauf HISTORY_FILE = os.path.join(BASE_DIR, "history.json") # Aktivitätsverlauf
HISTORY_MAX = 1000 # so viele Einträge werden aufbewahrt
HISTORY_PER_PAGE = 50 # Einträge je Seite im Dashboard-Verlauf
APP_VERSION = "6.2.2" APP_VERSION = "6.2.12"
UPLOAD_EXTENSIONS = {".jpg", ".jpeg", ".png", ".mp4"} UPLOAD_EXTENSIONS = {".jpg", ".jpeg", ".png", ".mp4"}
app = Flask(__name__) app = Flask(__name__)
app.secret_key = "CHANGE_THIS_SECRET!!!" app.secret_key = "CHANGE_THIS_SECRET!!!"
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1) app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1)
def remember_site(site):
"""Merkt den zuletzt besuchten Standort in der Session."""
if site:
session["last_site"] = site
def current_site_for(cfg, user=None):
"""Standort für Seiten ohne eigenen Standortbezug (Dashboard, Hilfe,
User-Verwaltung, MFA).
Nimmt den zuletzt besuchten Standort, sonst den ersten *zugänglichen*.
Ohne das springt die Standortanzeige im Header auf den global ersten
Standort - und der "Übersicht"-Link führt anschliessend dorthin statt
zurück zu dem Standort, von dem man kam."""
sites = get_accessible_sites(cfg, user) if (user is not None and user.is_authenticated) else get_site_list(cfg)
letzter = session.get("last_site")
if letzter in sites:
return letzter
return sites[0] if sites else ""
@app.context_processor @app.context_processor
def inject_globals(): def inject_globals():
"""Stellt globale Template-Variablen für Header/Footer bereit.""" """Stellt globale Template-Variablen für Header/Footer bereit."""
@@ -66,7 +90,7 @@ def inject_globals():
from flask_login import current_user from flask_login import current_user
if current_user.is_authenticated: if current_user.is_authenticated:
site_list = get_accessible_sites(cfg, current_user) site_list = get_accessible_sites(cfg, current_user)
current_site_val = get_site_list(cfg)[0] if get_site_list(cfg) else "" current_site_val = current_site_for(cfg, current_user)
else: else:
site_list = get_site_list(cfg) site_list = get_site_list(cfg)
current_site_val = site_list[0] if site_list else "" current_site_val = site_list[0] if site_list else ""
@@ -161,24 +185,33 @@ def add_history_entry(action, detail, user_email):
except (json.JSONDecodeError, OSError): except (json.JSONDecodeError, OSError):
history = [] history = []
history.insert(0, entry) # Neueste zuerst history.insert(0, entry) # Neueste zuerst
# Maximal 200 Einträge behalten # Maximal HISTORY_MAX Einträge behalten
history = history[:200] history = history[:HISTORY_MAX]
with open(HISTORY_FILE, "w") as f: with open(HISTORY_FILE, "w") as f:
json.dump(history, f, indent=2) json.dump(history, f, indent=2)
def get_history(limit=50): def _load_history():
"""Gibt die letzten history-Einträge zurück.""" """Liest den kompletten Verlauf; bei defekter Datei eine leere Liste."""
if not os.path.exists(HISTORY_FILE): if not os.path.exists(HISTORY_FILE):
return [] return []
try: try:
with open(HISTORY_FILE) as f: with open(HISTORY_FILE) as f:
history = json.load(f) return json.load(f)
return history[:limit]
except (json.JSONDecodeError, OSError): except (json.JSONDecodeError, OSError):
return [] return []
def get_history(limit=50, offset=0):
"""Gibt einen Ausschnitt des Verlaufs zurück (neueste zuerst)."""
return _load_history()[offset:offset + limit]
def get_history_count():
"""Anzahl aller gespeicherten Verlaufseintraege (fuer die Seitennavigation)."""
return len(_load_history())
def get_screen_config(cfg, site, screen): def get_screen_config(cfg, site, screen):
"""Holt die Konfiguration für einen bestimmten Screen. """Holt die Konfiguration für einen bestimmten Screen.
Erzeugt automatisch fehlende Dict-Ebenen (sites → site → screens → screen).""" Erzeugt automatisch fehlende Dict-Ebenen (sites → site → screens → screen)."""
@@ -380,16 +413,38 @@ def is_item_active(item):
# ------------------------------------------------- # -------------------------------------------------
# Priority-Playlist-Helfer # Priority-Playlist-Helfer
# ------------------------------------------------- # -------------------------------------------------
def load_priority_files(): PRIORITY_DIRNAME = "priority"
"""Lädt die Priority-Playlist aus config.json plus die dazugehörigen Dateien
aus media/priority/. Gibt eine Liste von Dicts mit name/type/size/enabled zurück.
def priority_dir(site):
"""Verzeichnis der Priority-Medien eines Standorts: media/<site>/priority/."""
return os.path.join(MEDIA_DIR, site, PRIORITY_DIRNAME)
def get_priority_config(cfg, site):
"""Priority-Konfiguration eines Standorts (erzeugt fehlende Ebenen).
Die Priority-Playlist gilt ausschliesslich fuer den eigenen Standort.
Aeltere Konfigurationen hatten sie global unter cfg["priority"]; ist beim
Standort noch nichts hinterlegt, wird der Altbestand einmalig uebernommen."""
site_cfg = cfg.setdefault("sites", {}).setdefault(site, {})
if "priority" not in site_cfg:
alt = cfg.get("priority")
site_cfg["priority"] = copy.deepcopy(alt) if isinstance(alt, dict) else {}
return site_cfg["priority"]
def load_priority_files(site):
"""Lädt die Priority-Playlist eines Standorts aus config.json plus die
dazugehörigen Dateien aus media/<site>/priority/.
Gibt eine Liste von Dicts mit name/type/size/enabled zurück.
Neue Dateien im Ordner, die noch nicht in der Playlist sind, werden angehängt.""" Neue Dateien im Ordner, die noch nicht in der Playlist sind, werden angehängt."""
cfg = load_config() cfg = load_config()
prio_dir = os.path.join(MEDIA_DIR, "priority") prio_dir = priority_dir(site)
os.makedirs(prio_dir, exist_ok=True) os.makedirs(prio_dir, exist_ok=True)
priority_files = [] priority_files = []
prio_playlist = cfg.get("priority", {}).get("playlist", []) prio_playlist = get_priority_config(cfg, site).get("playlist", [])
# 1. Einträge aus der config.json-Playlist # 1. Einträge aus der config.json-Playlist
for item in prio_playlist: for item in prio_playlist:
@@ -402,7 +457,8 @@ def load_priority_files():
"type": "url", "type": "url",
"size": "URL", "size": "URL",
"zoom": url_data.get("zoom", 1.0), "zoom": url_data.get("zoom", 1.0),
"enabled": is_item_active(item), "enabled": item.get("enabled", True) if isinstance(item, dict) else True,
"active": is_item_active(item),
"passthrough": item.get("passthrough_params", False) if isinstance(item, dict) else False, "passthrough": item.get("passthrough_params", False) if isinstance(item, dict) else False,
"schedule": item.get("schedule") if isinstance(item, dict) else None "schedule": item.get("schedule") if isinstance(item, dict) else None
}) })
@@ -425,7 +481,8 @@ def load_priority_files():
"name": item_name, "name": item_name,
"type": ftype, "type": ftype,
"size": size, "size": size,
"enabled": is_item_active(item), "enabled": item.get("enabled", True) if isinstance(item, dict) else True,
"active": is_item_active(item),
"schedule": item.get("schedule") if isinstance(item, dict) else None "schedule": item.get("schedule") if isinstance(item, dict) else None
}) })
@@ -687,29 +744,29 @@ def login():
trust_token = request.cookies.get("mfa_trust") trust_token = request.cookies.get("mfa_trust")
if trust_token: if trust_token:
now = datetime.now() now = datetime.now()
# Abgelaufene Einträge bereinigen treffer = None
active = [d for d in user.trusted_devices aktiv = []
if d.get("token") != trust_token or
datetime.fromisoformat(d["expires"]) > now]
changed = len(active) != len(user.trusted_devices)
for d in user.trusted_devices: for d in user.trusted_devices:
if d.get("token") == trust_token:
try: try:
expires = datetime.fromisoformat(d["expires"]) expires = datetime.fromisoformat(d["expires"])
if expires > now: except (ValueError, KeyError, TypeError):
continue # kaputter Eintrag fliegt raus
if expires <= now:
continue # abgelaufener Eintrag fliegt raus
aktiv.append(d)
if d.get("token") == trust_token:
treffer = d
if len(aktiv) != len(user.trusted_devices):
users_data = load_users()
users_data.setdefault(email, {})["trusted_devices"] = aktiv
save_users(users_data)
if treffer:
# Trusted Device gültig → MFA überspringen # Trusted Device gültig → MFA überspringen
login_user(user) login_user(user)
add_history_entry("user_login", f"User '{email}' angemeldet (Trusted Device)", email) add_history_entry("user_login", f"User '{email}' angemeldet (Trusted Device)", email)
if user.must_change_password: if user.must_change_password:
return redirect("/change-password") return redirect("/change-password")
return redirect("/admin") return redirect("/admin")
else:
pass
except (ValueError, KeyError):
pass
if changed:
users_data[email]["trusted_devices"] = active
save_users(users_data)
session["mfa_user"] = email session["mfa_user"] = email
session["mfa_password_ok"] = True session["mfa_password_ok"] = True
return redirect("/mfa/verify") return redirect("/mfa/verify")
@@ -784,7 +841,7 @@ def mfa_verify():
resp.set_cookie("mfa_trust", token, max_age=days * 86400, resp.set_cookie("mfa_trust", token, max_age=days * 86400,
path="/", path="/",
httponly=True, samesite="Lax", httponly=True, samesite="Lax",
secure=False) secure=request.is_secure)
return resp return resp
except (ValueError, TypeError): except (ValueError, TypeError):
pass pass
@@ -840,11 +897,11 @@ def mfa_setup():
save_users(users_data) save_users(users_data)
add_history_entry("mfa_enabled", f"User '{email}' hat MFA aktiviert", email) add_history_entry("mfa_enabled", f"User '{email}' hat MFA aktiviert", email)
return render_template("mfa_recovery.html", codes=recovery, email=email, return render_template("mfa_recovery.html", codes=recovery, email=email,
site_list=site_list, current_site=site_list[0] if site_list else "", site_list=site_list, current_site=current_site_for(cfg, current_user),
brand_subtitle="MFA", site_dropdown_url_prefix="/admin/") brand_subtitle="MFA", site_dropdown_url_prefix="/admin/")
return render_template("mfa_setup.html", error="Code ungültig. Bitte versuche es erneut.", return render_template("mfa_setup.html", error="Code ungültig. Bitte versuche es erneut.",
email=email, secret=secret, qr_data=None, email=email, secret=secret, qr_data=None,
site_list=site_list, current_site=site_list[0] if site_list else "", site_list=site_list, current_site=current_site_for(cfg, current_user),
brand_subtitle="MFA", site_dropdown_url_prefix="/admin/") brand_subtitle="MFA", site_dropdown_url_prefix="/admin/")
uri = totp.provisioning_uri(name=email, issuer_name="CANCOM Signage") uri = totp.provisioning_uri(name=email, issuer_name="CANCOM Signage")
@@ -856,7 +913,7 @@ def mfa_setup():
return render_template("mfa_setup.html", error=None if not error_param else "Passwort ist falsch", return render_template("mfa_setup.html", error=None if not error_param else "Passwort ist falsch",
email=email, secret=secret, qr_data=qr_b64, email=email, secret=secret, qr_data=qr_b64,
recovered=recovered, disabled=disabled, recovered=recovered, disabled=disabled,
site_list=site_list, current_site=site_list[0] if site_list else "", site_list=site_list, current_site=current_site_for(cfg, current_user),
brand_subtitle="MFA", site_dropdown_url_prefix="/admin/") brand_subtitle="MFA", site_dropdown_url_prefix="/admin/")
@@ -896,7 +953,6 @@ def mfa_disable():
# ------------------------------------------------- # -------------------------------------------------
@app.route("/willkommen", methods=["GET", "POST"]) @app.route("/willkommen", methods=["GET", "POST"])
@app.route("/customer", methods=["GET", "POST"]) @app.route("/customer", methods=["GET", "POST"])
@login_required
def add_customer(): def add_customer():
""" """
Formular für die Willkommensseite: Formular für die Willkommensseite:
@@ -914,16 +970,25 @@ def add_customer():
logo_hinweise = [] logo_hinweise = []
site = request.args.get("site") or request.form.get("site") or "" site = request.args.get("site") or request.form.get("site") or ""
# Fallback auf ersten für den User zugänglichen Standort # Diese Seite ist bewusst ohne Login erreichbar: Am Empfang soll eine
if not site: # Begruessung auch ohne Benutzerkonto erstellt werden koennen. Ein
# angemeldeter User sieht weiterhin nur seine freigegebenen Standorte.
config = load_config() config = load_config()
sites = get_accessible_sites(config, current_user) if current_user.is_authenticated:
site = sites[0] if sites else "" erlaubte_sites = get_accessible_sites(config, current_user)
else:
erlaubte_sites = get_site_list(config)
# Prüfen, ob der User Zugriff auf den Standort hat # Fallback auf den ersten zugaenglichen Standort
if site and not current_user.can_access_site(site): if not site:
site = erlaubte_sites[0] if erlaubte_sites else ""
# Zugriff auf den Standort pruefen
if site and site not in erlaubte_sites:
abort(403) abort(403)
remember_site(site)
if request.method == "POST": if request.method == "POST":
customer_names = [ customer_names = [
request.form.get("customer_name_1", "").strip(), request.form.get("customer_name_1", "").strip(),
@@ -1024,7 +1089,7 @@ def add_customer():
"contacts": list(contacts) "contacts": list(contacts)
} }
save_config(config) save_config(config)
add_history_entry("customer_added", f"Kunden ({', '.join(valid_names)}) zur Willkommensseite an Standort '{site}' hinzugefügt (Screens: {', '.join(targets)})", current_user.email) add_history_entry("customer_added", f"Kunden ({', '.join(valid_names)}) zur Willkommensseite an Standort '{site}' hinzugefügt (Screens: {', '.join(targets)})", current_user.email if current_user.is_authenticated else "nicht angemeldet")
success = (f"✅ Willkommensseite mit {len(valid_names)} Kunden erstellt " success = (f"✅ Willkommensseite mit {len(valid_names)} Kunden erstellt "
f"– angezeigt auf: {', '.join(targets)}") f"– angezeigt auf: {', '.join(targets)}")
if valid_until: if valid_until:
@@ -1143,18 +1208,41 @@ def api_add_customer():
# ------------------------------------------------- # -------------------------------------------------
# Medien ausliefern (statische Dateien) # Medien ausliefern (statische Dateien)
# ------------------------------------------------- # -------------------------------------------------
@app.route("/media/priority/<path:filename>") # Traegt die URL ein ?v=<mtime>, darf der Player die Datei ein Jahr lang ohne
def media_priority(filename): # Rueckfrage aus dem Cache nehmen: eine geaenderte Datei bekommt eine neue URL.
"""Liefert globale Priority-Medien aus media/priority/ aus.""" # Ohne ?v bleibt es beim Flask-Standard (no-cache + ETag), sonst saehe ein
path = os.path.join(MEDIA_DIR, "priority") # unversionierter Aufruf nach dem Ersetzen einer Datei weiter die alte Fassung.
return send_from_directory(path, filename) MEDIA_CACHE_MAX_AGE = 365 * 24 * 3600
def media_version(path):
"""Liefert die mtime einer Datei als Versionskennung fuer ?v=, 0 wenn sie fehlt."""
try:
return int(os.path.getmtime(path))
except OSError:
return 0
def send_media(directory, filename):
"""send_from_directory mit langem Cache, sofern die URL versioniert ist."""
if not request.args.get("v"):
return send_from_directory(directory, filename)
resp = send_from_directory(directory, filename, max_age=MEDIA_CACHE_MAX_AGE)
resp.cache_control.immutable = True
return resp
@app.route("/media/<site>/priority/<path:filename>")
def media_priority(site, filename):
"""Liefert die Priority-Medien eines Standorts aus media/<site>/priority/ aus."""
return send_media(priority_dir(site), filename)
@app.route("/media/<site>/<screen>/<path:filename>") @app.route("/media/<site>/<screen>/<path:filename>")
def media(site, screen, filename): def media(site, screen, filename):
"""Liefert Medien-Dateien für einen bestimmten Screen aus.""" """Liefert Medien-Dateien für einen bestimmten Screen aus."""
path = os.path.join(MEDIA_DIR, site, screen) path = os.path.join(MEDIA_DIR, site, screen)
return send_from_directory(path, filename) return send_media(path, filename)
# ------------------------------------------------- # -------------------------------------------------
@@ -1210,11 +1298,13 @@ def player(site, screen):
if is_url(item): if is_url(item):
url_data = normalize_url(item) url_data = normalize_url(item)
if url_data: if url_data:
normal_files.append({"kind": "url", "url": url_data["url"], "zoom": url_data.get("zoom", 1.0), "enabled": is_item_active(item), "passthrough": item.get("passthrough_params", False) if isinstance(item, dict) else False, "schedule": item.get("schedule") if isinstance(item, dict) else None}) normal_files.append({"kind": "url", "url": url_data["url"], "zoom": url_data.get("zoom", 1.0), "enabled": item.get("enabled", True) if isinstance(item, dict) else True,
"active": is_item_active(item), "passthrough": item.get("passthrough_params", False) if isinstance(item, dict) else False, "schedule": item.get("schedule") if isinstance(item, dict) else None})
continue continue
if item_name and os.path.exists(os.path.join(folder, item_name)) and allowed_file(item_name): if item_name and os.path.exists(os.path.join(folder, item_name)) and allowed_file(item_name):
normal_files.append({"kind": "file", "name": item_name, "enabled": True}) normal_files.append({"kind": "file", "name": item_name, "enabled": True,
"v": media_version(os.path.join(folder, item_name))})
existing_file_names = { existing_file_names = {
item["name"] for item in normal_files if item["kind"] == "file" item["name"] for item in normal_files if item["kind"] == "file"
@@ -1226,10 +1316,11 @@ def player(site, screen):
if f in existing_file_names or f.startswith("._"): if f in existing_file_names or f.startswith("._"):
continue continue
if allowed_file(f): if allowed_file(f):
normal_files.append({"kind": "file", "name": f}) normal_files.append({"kind": "file", "name": f,
"v": media_version(os.path.join(folder, f))})
# ─── Priority-Playlist (global) ─── # ─── Priority-Playlist (global) ───
priority_cfg = config.get("priority", {}) priority_cfg = get_priority_config(config, site)
if priority_cfg.get("enabled", False): if priority_cfg.get("enabled", False):
prio_files = [] prio_files = []
@@ -1240,12 +1331,14 @@ def player(site, screen):
if is_url(item): if is_url(item):
url_data = normalize_url(item) url_data = normalize_url(item)
if url_data: if url_data:
prio_files.append({"kind": "url", "url": url_data["url"], "zoom": url_data.get("zoom", 1.0), "enabled": is_item_active(item), "passthrough": item.get("passthrough_params", False) if isinstance(item, dict) else False, "schedule": item.get("schedule") if isinstance(item, dict) else None}) prio_files.append({"kind": "url", "url": url_data["url"], "zoom": url_data.get("zoom", 1.0), "enabled": item.get("enabled", True) if isinstance(item, dict) else True,
"active": is_item_active(item), "passthrough": item.get("passthrough_params", False) if isinstance(item, dict) else False, "schedule": item.get("schedule") if isinstance(item, dict) else None})
continue continue
file_path = os.path.join(MEDIA_DIR, "priority", item_name) file_path = os.path.join(priority_dir(site), item_name)
if os.path.exists(file_path): if os.path.exists(file_path):
prio_files.append({"kind": "file", "name": item_name, "enabled": True}) prio_files.append({"kind": "file", "name": item_name, "enabled": True,
"v": media_version(file_path)})
else: else:
prio_files = [] prio_files = []
@@ -1253,8 +1346,10 @@ def player(site, screen):
"player.html", "player.html",
site=site, site=site,
screen=screen, screen=screen,
normal_files=[item for item in normal_files if item.get("enabled", True)], normal_files=[item for item in normal_files
prio_files=[item for item in prio_files if item.get("enabled", True)], if item.get("enabled", True) and item.get("active", True)],
prio_files=[item for item in prio_files
if item.get("enabled", True) and item.get("active", True)],
interval=screen_cfg.get("interval", 10), interval=screen_cfg.get("interval", 10),
newsticker_text=screen_cfg.get("newsticker_text", ""), newsticker_text=screen_cfg.get("newsticker_text", ""),
newsticker_enabled=screen_cfg.get("newsticker_enabled", False), newsticker_enabled=screen_cfg.get("newsticker_enabled", False),
@@ -1296,7 +1391,7 @@ def playlist_hash(site, screen):
screen_playlist = ( screen_playlist = (
config.get("sites", {}).get(site, {}).get("screens", {}).get(screen, {}).get("playlist", []) config.get("sites", {}).get(site, {}).get("screens", {}).get(screen, {}).get("playlist", [])
) )
prio_cfg = config.get("priority", {}) prio_cfg = get_priority_config(config, site)
prio_playlist = prio_cfg.get("playlist", []) if prio_cfg.get("enabled", False) else [] prio_playlist = prio_cfg.get("playlist", []) if prio_cfg.get("enabled", False) else []
relevant = { relevant = {
@@ -1382,6 +1477,7 @@ def admin(site):
Admin-Dashboard für einen Standort. Admin-Dashboard für einen Standort.
Zeigt alle Screens als Cards mit Tabler-Tabs (Playlist, Einstellungen, Medien). Zeigt alle Screens als Cards mit Tabler-Tabs (Playlist, Einstellungen, Medien).
""" """
remember_site(site)
cfg = load_config() cfg = load_config()
site_list = get_accessible_sites(cfg, current_user) site_list = get_accessible_sites(cfg, current_user)
@@ -1409,15 +1505,17 @@ def admin(site):
config_screens = site_cfg.setdefault("screens", {}) config_screens = site_cfg.setdefault("screens", {})
# Ordner im Medienverzeichnis = Screens. # Ordner im Medienverzeichnis = Screens.
# "logos" ist reserviert: aeltere Versionen legten dort hochgeladene # Reservierte Namen: "logos" (hochgeladene Kundenlogos) und "priority"
# Kundenlogos ab, und ein solcher Ordner wuerde sonst dauerhaft als Screen # (Priority-Medien des Standorts). Solche Ordner wuerden sonst dauerhaft als
# in der Konfiguration landen. # Screen in der Konfiguration landen; "priority" ist ausserdem in den Routen
# bereits als Kennzeichen fuer die Priority-Playlist belegt.
reserviert = {generate_welcome_page.LOGO_URL_PREFIX, PRIORITY_DIRNAME}
if os.path.isdir(site_media_base): if os.path.isdir(site_media_base):
for entry in os.listdir(site_media_base): for entry in os.listdir(site_media_base):
entry_path = os.path.join(site_media_base, entry) entry_path = os.path.join(site_media_base, entry)
if (os.path.isdir(entry_path) if (os.path.isdir(entry_path)
and not entry.startswith("._") and not entry.startswith("._")
and entry != generate_welcome_page.LOGO_URL_PREFIX): and entry not in reserviert):
known_screens.add(entry) known_screens.add(entry)
# Screens aus Config ergänzen (auch ohne Ordner) # Screens aus Config ergänzen (auch ohne Ordner)
@@ -1447,7 +1545,8 @@ def admin(site):
"type": "url", "type": "url",
"size": "URL", "size": "URL",
"zoom": url_data.get("zoom", 1.0), "zoom": url_data.get("zoom", 1.0),
"enabled": is_item_active(item), "enabled": item.get("enabled", True) if isinstance(item, dict) else True,
"active": is_item_active(item),
"passthrough": item.get("passthrough_params", False) if isinstance(item, dict) else False, "passthrough": item.get("passthrough_params", False) if isinstance(item, dict) else False,
"schedule": schedule "schedule": schedule
}) })
@@ -1471,7 +1570,8 @@ def admin(site):
"name": item_name, "name": item_name,
"type": ftype, "type": ftype,
"size": size, "size": size,
"enabled": is_item_active(item), "enabled": item.get("enabled", True) if isinstance(item, dict) else True,
"active": is_item_active(item),
"schedule": schedule "schedule": schedule
}) })
@@ -1498,12 +1598,21 @@ def admin(site):
screen_status[screen_name] = "online" if screen_is_active(current_site, screen_name) else "offline" screen_status[screen_name] = "online" if screen_is_active(current_site, screen_name) else "offline"
client_info[screen_name] = get_client_info(current_site, screen_name) client_info[screen_name] = get_client_info(current_site, screen_name)
# Priority-Einträge des Standorts nur zur Anzeige mitgeben: sie gelten für
# alle Screens, sind aber in einem eigenen Menüpunkt und werden hier leicht
# übersehen. Bedienung bleibt ausschließlich auf der Priority-Seite.
prio_cfg = get_priority_config(cfg, current_site) if current_site else {}
priority_preview = []
if current_site and prio_cfg.get("enabled", False):
priority_preview = [f for f in load_priority_files(current_site) if "enabled" in f]
return render_template( return render_template(
"admin.html", "admin.html",
site_list=site_list, site_list=site_list,
current_site=current_site, current_site=current_site,
screens=screens, screens=screens,
media_files=media_files, media_files=media_files,
priority_preview=priority_preview,
screen_status=screen_status, screen_status=screen_status,
client_info=client_info, client_info=client_info,
version=APP_VERSION, version=APP_VERSION,
@@ -1521,9 +1630,10 @@ def admin(site):
@site_access_required @site_access_required
def admin_priority(site): def admin_priority(site):
"""Zeigt die Priority-Playlist-Seite mit Tabs (Playlist + Medien).""" """Zeigt die Priority-Playlist-Seite mit Tabs (Playlist + Medien)."""
remember_site(site)
cfg = load_config() cfg = load_config()
site_list = get_accessible_sites(cfg, current_user) site_list = get_accessible_sites(cfg, current_user)
priority_files = load_priority_files() priority_files = load_priority_files(site)
return render_template( return render_template(
"priority.html", "priority.html",
@@ -1719,7 +1829,8 @@ def upload(site, screen):
filename = secure_filename(file.filename) filename = secure_filename(file.filename)
if screen == "priority": if screen == "priority":
file.save(os.path.join(MEDIA_DIR, "priority", filename)) os.makedirs(priority_dir(site), exist_ok=True)
file.save(os.path.join(priority_dir(site), filename))
else: else:
file.save(os.path.join(MEDIA_DIR, site, screen, filename)) file.save(os.path.join(MEDIA_DIR, site, screen, filename))
@@ -1790,7 +1901,7 @@ def add_url(site, screen):
config = load_config() config = load_config()
if screen == "priority": if screen == "priority":
config.setdefault("priority", {}).setdefault("playlist", []).append(url_data) get_priority_config(config, site).setdefault("playlist", []).append(url_data)
else: else:
cfg = get_screen_config(config, site, screen) cfg = get_screen_config(config, site, screen)
cfg.setdefault("playlist", []).append(url_data) cfg.setdefault("playlist", []).append(url_data)
@@ -1809,20 +1920,17 @@ def delete_file(site, screen, filename):
cfg = load_config() cfg = load_config()
if screen == "priority": if screen == "priority":
path = os.path.join(MEDIA_DIR, "priority", filename) path = os.path.join(priority_dir(site), filename)
playlist = cfg.get("priority", {}).get("playlist", []) playlist = get_priority_config(cfg, site).get("playlist", [])
playlist[:] = [item for item in playlist if not (
(isinstance(item, str) and item == filename) or
(isinstance(item, dict) and item.get("url") == filename)
)]
else: else:
path = os.path.join(MEDIA_DIR, site, screen, filename) path = os.path.join(MEDIA_DIR, site, screen, filename)
screens_cfg = cfg.get("sites", {}).get(site, {}).get("screens", {}) screens_cfg = cfg.get("sites", {}).get(site, {}).get("screens", {})
playlist = screens_cfg.get(screen, {}).get("playlist", []) playlist = screens_cfg.get(screen, {}).get("playlist", [])
playlist[:] = [item for item in playlist if not (
(isinstance(item, str) and item == filename) or # Ueber playlist_item_name vergleichen: Datei-Eintraege stehen als
(isinstance(item, dict) and item.get("url") == filename) # {"name": ...}, URLs als {"url": ...}, Altbestand als reiner String.
)] playlist[:] = [item for item in playlist
if playlist_item_name(item) != filename]
if os.path.exists(path): if os.path.exists(path):
os.remove(path) os.remove(path)
@@ -1847,7 +1955,7 @@ def save_playlist(site, screen):
enabled_map = data.get("enabled", {}) enabled_map = data.get("enabled", {})
if screen == "priority": if screen == "priority":
old_playlist = cfg.get("priority", {}).get("playlist", []) old_playlist = get_priority_config(cfg, site).get("playlist", [])
else: else:
screens_cfg = cfg.get("sites", {}).get(site, {}).get("screens", {}) screens_cfg = cfg.get("sites", {}).get(site, {}).get("screens", {})
old_playlist = screens_cfg.get(screen, {}).get("playlist", []) old_playlist = screens_cfg.get(screen, {}).get("playlist", [])
@@ -1868,7 +1976,7 @@ def save_playlist(site, screen):
new_playlist.append({"url": key, "zoom": 1.0, "enabled": enabled_map.get(key, True)} if is_url(key) else {"name": key, "enabled": enabled_map.get(key, True)}) new_playlist.append({"url": key, "zoom": 1.0, "enabled": enabled_map.get(key, True)} if is_url(key) else {"name": key, "enabled": enabled_map.get(key, True)})
if screen == "priority": if screen == "priority":
cfg.setdefault("priority", {})["playlist"] = new_playlist get_priority_config(cfg, site)["playlist"] = new_playlist
else: else:
cfg.setdefault("sites", {}).setdefault(site, {}).setdefault("screens", {}).setdefault(screen, {})["playlist"] = new_playlist cfg.setdefault("sites", {}).setdefault(site, {}).setdefault("screens", {}).setdefault(screen, {})["playlist"] = new_playlist
@@ -1907,7 +2015,7 @@ def update_playlist_item(site, screen):
config = load_config() config = load_config()
if screen == "priority": if screen == "priority":
playlist = config.setdefault("priority", {}).setdefault("playlist", []) playlist = get_priority_config(config, site).setdefault("playlist", [])
else: else:
playlist = config["sites"][site]["screens"][screen].setdefault("playlist", []) playlist = config["sites"][site]["screens"][screen].setdefault("playlist", [])
@@ -2010,8 +2118,19 @@ def admin_dashboard():
admin_count = sum(1 for u in users_data.values() if u.get("role") == "admin") admin_count = sum(1 for u in users_data.values() if u.get("role") == "admin")
superuser_count = sum(1 for u in users_data.values() if u.get("role") == "superuser") superuser_count = sum(1 for u in users_data.values() if u.get("role") == "superuser")
# Kennzahlen und Sparklines bleiben bewusst am jüngsten Ausschnitt haengen,
# damit sie sich beim Blaettern im Verlauf nicht veraendern.
history = get_history(30) history = get_history(30)
gesamt = get_history_count()
seiten = max(1, (gesamt + HISTORY_PER_PAGE - 1) // HISTORY_PER_PAGE)
try:
seite = int(request.args.get("page", 1))
except ValueError:
seite = 1
seite = min(max(seite, 1), seiten)
history_page = get_history(HISTORY_PER_PAGE, (seite - 1) * HISTORY_PER_PAGE)
# Sparkline-Daten aus dem Verlauf berechnen # Sparkline-Daten aus dem Verlauf berechnen
def build_sparkline(entries, creates, deletes): def build_sparkline(entries, creates, deletes):
seq = list(reversed(entries)) seq = list(reversed(entries))
@@ -2060,9 +2179,14 @@ def admin_dashboard():
user_trend=user_trend, user_trend=user_trend,
admin_trend=admin_trend, admin_trend=admin_trend,
superuser_trend=superuser_trend, superuser_trend=superuser_trend,
history=history, history=history_page,
history_page=seite,
history_pages=seiten,
history_total=gesamt,
history_from=(seite - 1) * HISTORY_PER_PAGE + 1 if gesamt else 0,
history_to=min(seite * HISTORY_PER_PAGE, gesamt),
site_list=get_accessible_sites(cfg, current_user), site_list=get_accessible_sites(cfg, current_user),
current_site=get_site_list(cfg)[0] if get_site_list(cfg) else "", current_site=current_site_for(cfg, current_user),
version=APP_VERSION, version=APP_VERSION,
year=datetime.now().year, year=datetime.now().year,
hostname=os.uname().nodename, hostname=os.uname().nodename,
@@ -2078,7 +2202,7 @@ def admin_help():
return render_template( return render_template(
"help.html", "help.html",
site_list=get_accessible_sites(cfg, current_user), site_list=get_accessible_sites(cfg, current_user),
current_site=get_site_list(cfg)[0] if get_site_list(cfg) else "", current_site=current_site_for(cfg, current_user),
version=APP_VERSION, version=APP_VERSION,
year=datetime.now().year, year=datetime.now().year,
hostname=os.uname().nodename, hostname=os.uname().nodename,
@@ -2111,7 +2235,7 @@ def admin_users():
"user_list.html", "user_list.html",
users=sorted(users_list, key=lambda u: (u["last_name"].lower(), u["first_name"].lower())), users=sorted(users_list, key=lambda u: (u["last_name"].lower(), u["first_name"].lower())),
site_list=get_accessible_sites(cfg, current_user), site_list=get_accessible_sites(cfg, current_user),
current_site=get_site_list(cfg)[0] if get_site_list(cfg) else "", current_site=current_site_for(cfg, current_user),
version=APP_VERSION, version=APP_VERSION,
year=datetime.now().year, year=datetime.now().year,
hostname=os.uname().nodename, hostname=os.uname().nodename,

View File

@@ -1,25 +1,6 @@
{ {
"server_url": "http://localhost:5005", "server_url": "http://localhost:5005",
"voice_agent_url": "https://dva-stage.buds.cancom.ai/", "voice_agent_url": "https://dva-stage.buds.cancom.ai/",
"priority": {
"enabled": true,
"playlist": [
{
"url": "https://wbxroompresence.cancom.io/standort?find=Stuttgart",
"zoom": 0.8,
"enabled": false,
"passthrough_params": false,
"schedule": {
"mode": "weekly",
"start": "08:00",
"end": "09:00",
"days": [
"0"
]
}
}
]
},
"sites": { "sites": {
"stuttgart": { "stuttgart": {
"screens": { "screens": {
@@ -76,11 +57,13 @@
"enabled": true "enabled": true
} }
], ],
"newsticker_text": "Herzlich willkommen bei der CANCOM - wir w\u00fcnschen ihnen einen sch\u00f6nen Tag", "newsticker_text": "",
"newsticker_enabled": true, "newsticker_enabled": false,
"custom_url": "https://www.meteoblue.com/en/meteotv/d7b0fd", "custom_url": "https://www.meteoblue.com/en/meteotv/d7b0fd",
"custom_url_label": "Wetter", "custom_url_label": "Wetter",
"custom_url_enabled": false "custom_url_enabled": false,
"stay_on_first": false,
"welcome_enabled": false
}, },
"videosysteme": { "videosysteme": {
"interval": 20, "interval": 20,
@@ -102,6 +85,25 @@
"https://cdn.brandfetch.io/www.ibm.com/logo?c=1idyd4Tpb2nKaXIIc8T", "https://cdn.brandfetch.io/www.ibm.com/logo?c=1idyd4Tpb2nKaXIIc8T",
"https://cdn.brandfetch.io/www.soliver.com/logo?c=1idyd4Tpb2nKaXIIc8T" "https://cdn.brandfetch.io/www.soliver.com/logo?c=1idyd4Tpb2nKaXIIc8T"
] ]
},
"priority": {
"enabled": true,
"playlist": [
{
"url": "https://wbxroompresence.cancom.io/standort?find=Stuttgart",
"zoom": 0.8,
"enabled": false,
"passthrough_params": false,
"schedule": {
"mode": "weekly",
"start": "08:00",
"end": "09:00",
"days": [
"0"
]
}
}
]
} }
}, },
"karlsruhe": { "karlsruhe": {
@@ -152,6 +154,98 @@
"logo_urls": [ "logo_urls": [
"https://cdn.brandfetch.io/weisenburger.de/logo?c=1idyd4Tpb2nKaXIIc8T" "https://cdn.brandfetch.io/weisenburger.de/logo?c=1idyd4Tpb2nKaXIIc8T"
] ]
},
"priority": {
"enabled": true,
"playlist": [
{
"url": "https://wbxroompresence.cancom.io/standort?find=Stuttgart",
"zoom": 0.8,
"enabled": false,
"passthrough_params": false,
"schedule": {
"mode": "weekly",
"start": "08:00",
"end": "09:00",
"days": [
"0"
]
}
}
]
}
},
"test": {
"screens": {
"vorstand": {
"playlist": [
{
"name": "welcome.html",
"enabled": true
},
{
"name": "Artikelbild_Weindorf.jpg",
"enabled": true
}
],
"interval": 10,
"show_images": true,
"show_videos": true,
"newsticker_text": "",
"newsticker_enabled": false,
"stay_on_first": false,
"welcome_enabled": true
},
"casino": {
"playlist": [],
"interval": 10,
"show_images": true,
"show_videos": true,
"newsticker_text": "",
"newsticker_enabled": false,
"stay_on_first": false,
"welcome_enabled": false
}
},
"welcome_data": {
"names": [
"WIN",
"IBM",
"Gaufer"
],
"logo_urls": [
"/media/test/logos/logo_1.png?v=1788363702",
"https://cdn.brandfetch.io/www.ibm.com/logo?c=1idyd4Tpb2nKaXIIc8T",
"/media/test/logos/logo_3.png?v=1788364382"
],
"contacts": [
"",
"",
""
]
},
"priority": {
"enabled": true,
"playlist": [
{
"url": "https://wbxroompresence.cancom.io/standort?find=Stuttgart",
"zoom": 0.8,
"enabled": false,
"passthrough_params": false,
"schedule": {
"mode": "weekly",
"start": "08:00",
"end": "09:00",
"days": [
"0"
]
}
},
{
"name": "IMG_6249.jpeg",
"enabled": true
}
]
} }
} }
}, },

Binary file not shown.

4
docs/cancom.svg Executable file
View File

@@ -0,0 +1,4 @@
<?xml version="1.0"?>
<!-- Created with xTool Creative Space (https://www.xtool.com/pages/software) -->
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:xcs="https://www.xtool.com/pages/software" version="1.1" preserveAspectRatio="xMinYMin meet" width="61.682394106284505mm" height="9.909010347638684mm" viewBox="136.55880294685778 209.2954948261807 61.682394106284505 9.909010347638684" xcs:version="2.7.22"><style></style>
<path transform="matrix(0.158038,0,0,0.158038,133.2558,163.322122)" stroke="none" fill="#ffffff" data-view-type="laser" d="M125.9,352.1h18.9L119,291.7h-16.2l-25,60.4h18.2l14.6-38.9L125.9,352.1z M323.6,322 c0,8.8-6.1,16.4-15.4,16.4c-9.1,0-15.4-7.6-15.4-16.4s6.1-16.4,15.2-16.4C317,305.6,323.6,313.4,323.6,322 M340.5,322.3 c0-17.4-13.4-31.3-32.3-31.3c-18.7,0-32.3,14.1-32.3,31.3c0,17.4,13.4,31.3,32.3,31.3C326.9,353.6,340.5,339.4,340.5,322.3 M230.4,322.5c0-8.8,6.1-16.4,14.9-16.4c3,0,5.8,0.8,8.1,2l6.6-13.4c-3-2-8.1-3.8-14.9-3.8c-18.2,0-31.6,14.1-31.6,31.3 c0,18.2,12.6,31.1,31.6,31.1c17.2,0,26-10.6,28.8-21l-13.6-5.6c-1.8,5.8-6.3,11.6-14.4,11.6C236.5,338.4,230.4,331.4,230.4,322.5 M38,322.5c0-8.8,6.1-16.4,14.9-16.4c3,0,5.8,0.8,8.1,2l6.6-13.4c-3-2-8.1-3.8-14.9-3.8c-18.4,0-31.8,14.1-31.8,31.3 c0,18.2,12.6,31.1,31.6,31.1c17.2,0,26-10.6,28.8-21l-13.6-5.6c-1.8,5.8-6.3,11.6-14.4,11.6C44,338.4,38,331.4,38,322.5 M191.8,352.1h14.4v-59.8H190v33.1l-25-33.1h-15.4v60.1h16.2v-34.1L191.8,352.1z M411.2,352.1v-59.8h-17.7l-14.4,23.2l-14.4-23.2 h-17.7v60.1h16.4V318l15.4,23.2h0.3l15.4-23.5v34.6L411.2,352.1L411.2,352.1z" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

383
docs/onepager.html Normal file
View File

@@ -0,0 +1,383 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>CANCOM Simple Signage – Überblick</title>
<style>
@page { size: A4; margin: 12mm 13mm 11mm 13mm; }
:root {
--rot: #DA002D;
--dunkel: #2b2f36;
--grau: #5b6472;
--hellgrau: #f2f4f7;
--linie: #dfe3e9;
}
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 9.1pt;
line-height: 1.42;
color: var(--dunkel);
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.seite { page-break-after: always; }
.seite:last-child { page-break-after: auto; }
/* ── Kopf ── */
.kopf {
display: flex; align-items: center; gap: 10mm;
border-bottom: 2.5pt solid var(--rot);
padding-bottom: 3.5mm; margin-bottom: 5mm;
}
.logobox { background: var(--dunkel); padding: 2.5mm 4mm; border-radius: 1.2mm; }
.logobox img { height: 6.5mm; display: block; }
.kopf .titel { margin-left: auto; text-align: right; }
.kopf h1 { font-size: 16pt; margin: 0; letter-spacing: -0.2pt; }
.kopf .unter { font-size: 8.4pt; color: var(--grau); margin-top: 0.8mm; }
.kopf-klein {
display: flex; align-items: baseline;
border-bottom: 1.2pt solid var(--linie);
padding-bottom: 2.5mm; margin-bottom: 4.5mm;
}
.kopf-klein h2 { font-size: 12.5pt; margin: 0; color: var(--rot); }
.kopf-klein .seitezahl { margin-left: auto; font-size: 8pt; color: var(--grau); }
/* ── Einleitung ── */
.intro {
background: var(--hellgrau);
border-left: 3pt solid var(--rot);
padding: 3mm 4mm; margin-bottom: 5mm;
font-size: 9.4pt;
}
.intro strong { color: var(--rot); }
/* ── Raster ── */
.raster { display: grid; grid-template-columns: 1fr 1fr; gap: 4mm 6mm; }
.raster-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 3mm 5mm; }
h3 {
font-size: 9.6pt; margin: 0 0 1.2mm 0;
color: var(--dunkel);
display: flex; align-items: center; gap: 2mm;
}
h3::before {
content: ""; display: inline-block;
width: 3mm; height: 3mm; border-radius: 0.6mm;
background: var(--rot); flex: none;
}
h3.grau::before { background: var(--grau); }
p { margin: 0 0 1.6mm 0; }
.block { margin-bottom: 3.6mm; }
ul { margin: 0.5mm 0 0 0; padding-left: 4.2mm; }
li { margin-bottom: 0.7mm; }
.klein { font-size: 8.2pt; color: var(--grau); }
code {
font-family: "SF Mono", Menlo, Consolas, monospace;
font-size: 8.2pt;
background: var(--hellgrau);
padding: 0.3mm 1mm; border-radius: 0.8mm;
}
/* ── Ablauf-Schritte ── */
.schritte { display: grid; grid-template-columns: repeat(4, 1fr); gap: 3mm; margin-bottom: 5mm; }
.schritt {
border: 0.8pt solid var(--linie); border-radius: 1.5mm;
padding: 2.5mm 3mm; position: relative;
}
.schritt .nr {
display: inline-block; width: 5mm; height: 5mm; line-height: 5mm;
text-align: center; border-radius: 50%;
background: var(--rot); color: #fff;
font-size: 8pt; font-weight: bold; margin-bottom: 1.5mm;
}
.schritt b { display: block; font-size: 9pt; margin-bottom: 0.8mm; }
.schritt span { font-size: 8.2pt; color: var(--grau); line-height: 1.35; display: block; }
/* ── Tabelle ── */
table { width: 100%; border-collapse: collapse; font-size: 8.5pt; }
th, td { text-align: left; padding: 1.4mm 2mm; border-bottom: 0.6pt solid var(--linie); vertical-align: top; }
th { background: var(--hellgrau); font-size: 8.2pt; }
td:first-child { white-space: nowrap; font-weight: 600; }
/* ── Hinweiskasten ── */
.kasten {
border: 0.8pt solid var(--linie); border-radius: 1.5mm;
padding: 3mm 3.5mm; background: #fcfcfd;
}
.kasten h3 { margin-bottom: 1.8mm; }
.warn {
border-left: 3pt solid #f59f00; background: #fff9e6;
padding: 2.5mm 3.5mm; border-radius: 0 1.5mm 1.5mm 0;
margin-bottom: 4mm;
}
.badge {
display: inline-block; padding: 0.2mm 1.4mm; border-radius: 0.8mm;
font-size: 7.6pt; font-weight: 600; vertical-align: 0.3mm;
}
.b-rot { background: var(--rot); color: #fff; }
.b-blau { background: #206bc4; color: #fff; }
.b-grau { background: #e4e7ec; color: #555e6d; }
.b-gruen { background: #2fb344; color: #fff; }
/* ── Fuß ── */
.fuss {
border-top: 0.6pt solid var(--linie);
margin-top: 4mm; padding-top: 1.5mm;
font-size: 7.4pt; color: var(--grau);
display: flex;
}
.fuss .rechts { margin-left: auto; }
</style>
</head>
<body>
<!-- ══════════════ VORDERSEITE ══════════════ -->
<div class="seite">
<div class="kopf">
<span class="logobox"><img src="cancom.svg" alt="CANCOM"></span>
<div class="titel">
<h1>Simple Signage</h1>
<div class="unter">Infobildschirme selbst befüllen – ohne IT-Kenntnisse</div>
</div>
</div>
<div class="intro">
<strong>Was ist das?</strong> Simple Signage zeigt Bilder, Videos, Webseiten und Begrüßungen auf den
Info-Bildschirmen im Haus. Sie bestimmen im Browser, was wann auf welchem Bildschirm läuft – die
Änderung ist binnen Sekunden auf dem Schirm. Es wird nichts installiert: Sie brauchen nur einen Browser,
Ihre E-Mail-Adresse und Ihr Passwort.
</div>
<div class="schritte">
<div class="schritt">
<span class="nr">1</span>
<b>Anmelden</b>
<span>Browser öffnen, E-Mail und Passwort eingeben. Sie sehen nur die Standorte, für die Sie freigeschaltet sind.</span>
</div>
<div class="schritt">
<span class="nr">2</span>
<b>Bildschirm wählen</b>
<span>Jeder Bildschirm („Screen“) hat eine eigene Kachel mit seiner Abspielliste.</span>
</div>
<div class="schritt">
<span class="nr">3</span>
<b>Inhalte einstellen</b>
<span>Bilder oder Videos hochladen, Webseiten als Link eintragen, Reihenfolge per Maus ziehen.</span>
</div>
<div class="schritt">
<span class="nr">4</span>
<b>Fertig</b>
<span>Es gibt keinen Speichern-Knopf für die Liste – der Bildschirm übernimmt die Änderung von selbst.</span>
</div>
</div>
<div class="raster">
<div class="block">
<h3>Standort und Bildschirm</h3>
<p>Ein <b>Standort</b> ist ein Haus oder eine Niederlassung, zum Beispiel Stuttgart. Darin liegen die
einzelnen <b>Bildschirme</b> – Empfang, Kantine, Flur. Oben im Kopf wechseln Sie den Standort.</p>
<p>Jeder Bildschirm hat seine eigene Abspielliste. Was Sie am Empfang ändern, betrifft die Kantine nicht.</p>
</div>
<div class="block">
<h3>Die Abspielliste (Playlist)</h3>
<p>Die Liste wird von oben nach unten durchlaufen und beginnt danach von vorn. Pro Eintrag haben Sie:</p>
<ul>
<li><b>Haken</b> – Eintrag läuft mit oder pausiert</li>
<li><b>Griff links</b> – mit der Maus an eine andere Stelle ziehen</li>
<li><b>Stift</b> – Einstellungen des Eintrags ändern</li>
<li><b>Papierkorb</b> – Eintrag und Datei entfernen</li>
</ul>
</div>
<div class="block">
<h3>Was Sie anzeigen können</h3>
<table>
<tr><td>Bilder</td><td>JPG und PNG, am besten in Bildschirmgröße (1920×1080)</td></tr>
<tr><td>Videos</td><td>MP4, laufen ohne Ton</td></tr>
<tr><td>Webseiten</td><td>Als Adresse eintragen, z.&nbsp;B. ein Dashboard oder Intranet</td></tr>
<tr><td>Begrüßung</td><td>Wird im Formular erzeugt, siehe Rückseite</td></tr>
</table>
</div>
<div class="block">
<h3>Zeitsteuerung</h3>
<p>Jeder Eintrag kann auf ein Zeitfenster gelegt werden – <b>einmalig</b> (von Datum bis Datum),
<b>täglich</b> oder <b>wöchentlich</b> an bestimmten Wochentagen. Außerhalb des Fensters wird er
einfach übersprungen.</p>
<p class="klein">Typisch: Das Kantinenmenü nur von 11 bis 14 Uhr, der Veranstaltungshinweis nur bis zum Termin.</p>
</div>
<div class="block">
<h3>Haken und Zeit sind zweierlei</h3>
<p>Der <b>Haken</b> ist Ihr Schalter: gesetzt heißt „soll laufen“. Er bleibt gesetzt, auch wenn gerade
keine Sendezeit ist.</p>
<p>Das <b>Zeit-Badge</b> sagt, ob es gerade so weit ist:
<span class="badge b-blau">Zeit</span> läuft jetzt &nbsp;·&nbsp;
<span class="badge b-grau">Zeit</span> wartet auf sein Fenster.</p>
<p class="klein">Zu sehen ist ein Eintrag nur, wenn beides zutrifft: Haken gesetzt <i>und</i> im Zeitfenster.</p>
</div>
<div class="block">
<h3>Läuft der Bildschirm?</h3>
<p>Neben jeder Bildschirm-Kachel steht <span class="badge b-gruen">Online</span> oder
<span class="badge b-rot">Offline</span>. Grün heißt: Das Gerät hat sich in der letzten Minute gemeldet.</p>
<p class="klein">Bleibt es rot, ist meist das Gerät aus oder vom Netz getrennt – im Tab „Info“ stehen die
letzten Angaben dazu.</p>
</div>
</div>
<div class="kasten" style="margin-top:1mm;">
<h3 class="grau">So sieht ein typischer Arbeitstag damit aus</h3>
<div class="raster-3" style="margin-top:1.5mm;">
<div>
<b>Morgens: Besuch kündigt sich an</b>
<p class="klein">Unter „Willkommen“ den Firmennamen eintragen, Datum bis wann, fertig. Der Empfangsbildschirm
begrüßt den Gast, danach verschwindet die Seite von selbst.</p>
</div>
<div>
<b>Mittags: Speiseplan</b>
<p class="klein">Das Plakat als Bild hochladen und auf täglich 11–14 Uhr stellen. Ab dann kümmert es sich
jeden Tag von allein.</p>
</div>
<div>
<b>Kurzfristig: Hinweis für alle</b>
<p class="klein">„Aufzug außer Betrieb“ in die Priority-Liste – der Hinweis läuft sofort auf allen
Bildschirmen des Standorts mit.</p>
</div>
</div>
</div>
<div class="fuss">
<div>CANCOM Simple Signage – Kurzüberblick für Anwender</div>
<div class="rechts">Vorderseite &nbsp;·&nbsp; bitte wenden →</div>
</div>
</div>
<!-- ══════════════ RÜCKSEITE ══════════════ -->
<div class="seite">
<div class="kopf-klein">
<h2>Weitere Funktionen</h2>
<div class="seitezahl">CANCOM Simple Signage</div>
</div>
<div class="raster">
<div class="block">
<h3>Begrüßungsseite für Besuch</h3>
<p>Über den Menüpunkt <b>Willkommen</b> tragen Sie bis zu drei Firmennamen ein. Das Logo wird automatisch
gesucht; finden Sie es nicht passend, laden Sie selbst eines hoch. Dazu je ein Feld für den
Ansprechpartner.</p>
<ul>
<li>Die Seite erscheint ganz vorn auf allen Bildschirmen, die dafür eingestellt sind</li>
<li><b>Anzeigen bis:</b> Datum eintragen, dann verschwindet sie von selbst</li>
<li>Diese Seite können Sie auch <b>ohne Anmeldung</b> aufrufen – gedacht für den Empfang</li>
</ul>
</div>
<div class="block">
<h3>Priority-Liste: für alle gleichzeitig</h3>
<p>Was in die Priority-Liste eines Standorts kommt, läuft auf <b>allen</b> Bildschirmen dieses
Standorts mit – ohne dass Sie jede Liste einzeln anfassen. Gedacht für Ankündigungen, die alle
angehen.</p>
<p>In den normalen Listen sehen Sie diese Einträge grau hinterlegt mit dem Vermerk
<span class="badge b-rot">Prio</span>. Dort sind sie nur zur Ansicht – geändert wird im Menüpunkt
<b>Priority</b>.</p>
</div>
<div class="block">
<h3>Knöpfe auf dem Bildschirm</h3>
<p>Ist der Bildschirm berührungsempfindlich, lässt sich ein Knopf einblenden, der eine Seite öffnet –
etwa ein Formular oder den Lageplan. Position frei wählbar, mit Zurück-Knopf.</p>
<p>Ebenso der <b>Digital Voice Agent</b>: ein Sprachassistent, den Besucher selbst starten können.</p>
</div>
<div class="block">
<h3>Laufschrift und Ansicht</h3>
<ul>
<li><b>Newsticker:</b> Laufschrift am unteren Rand, pro Bildschirm</li>
<li><b>Stehenbleiben:</b> Der Bildschirm zeigt dauerhaft nur den ersten Eintrag, statt zu wechseln</li>
<li><b>Anzeigedauer:</b> Wie lange ein Bild stehen bleibt, bevor das nächste kommt</li>
</ul>
</div>
<div class="block">
<h3>Ihr Zugang</h3>
<ul>
<li>Beim ersten Anmelden vergeben Sie ein eigenes Passwort</li>
<li>Passwort vergessen? Ein Administrator setzt es zurück</li>
<li><b>Zwei-Faktor:</b> freiwillig einschaltbar – zusätzlich zum Passwort ein Zahlencode aus der
Authenticator-App. Ihr eigenes Gerät können Sie für bis zu 30 Tage merken lassen, dann entfällt
die Codeeingabe</li>
</ul>
</div>
<div class="block">
<h3>Wer darf was</h3>
<table>
<tr><td>Anwender</td><td>Pflegt die Bildschirme der ihm zugewiesenen Standorte</td></tr>
<tr><td>Superuser</td><td>Pflegt alle Standorte</td></tr>
<tr><td>Administrator</td><td>Zusätzlich Benutzer, Standorte und Bildschirme verwalten</td></tr>
</table>
</div>
</div>
<div class="warn">
<b>Drei Dinge, die häufig für Verwirrung sorgen</b>
<ul style="margin-top:1.2mm;">
<li>Ein zeitgesteuerter Eintrag sieht außerhalb seines Fensters „untätig“ aus – das ist richtig so. Prüfen
Sie das graue Zeit-Badge, bevor Sie etwas ändern.</li>
<li>Priority-Einträge stehen in der normalen Liste nur zur Ansicht. Ändern lassen sie sich ausschließlich
im Menüpunkt Priority.</li>
<li>Die Bildschirme selbst melden sich nicht an. Wer die Adresse kennt, sieht den Inhalt – legen Sie dort
nichts Vertrauliches ab.</li>
</ul>
</div>
<div class="kasten">
<h3 class="grau">Wenn etwas nicht so aussieht wie erwartet</h3>
<div class="raster-3" style="margin-top:1.5mm;">
<div>
<b>Der Bildschirm zeigt Altes</b>
<p class="klein">Er prüft alle fünf Sekunden auf Änderungen. Dauert es länger, steht der Status
vermutlich auf Offline.</p>
</div>
<div>
<b>Ein Eintrag fehlt</b>
<p class="klein">Haken gesetzt? Zeitfenster aktiv? Beides muss zutreffen, damit etwas erscheint.</p>
</div>
<div>
<b>Das Video ruckelt</b>
<p class="klein">MP4 mit H.264 und höchstens 1920×1080 verwenden. Größere Dateien überfordern
die Abspielgeräte.</p>
</div>
</div>
<p class="klein" style="margin-top:2.5mm; margin-bottom:0;">
Ausführliche Erklärungen zu jeder Funktion finden Sie jederzeit in der Oberfläche unter
<b>Hilfe</b> – im Menü hinter Ihrer E-Mail-Adresse oben rechts.</p>
</div>
<div class="fuss">
<div>CANCOM Simple Signage – Kurzüberblick für Anwender</div>
<div class="rechts">Rückseite</div>
</div>
</div>
</body>
</html>

View File

@@ -1,4 +1,304 @@
[ [
{
"timestamp": "2026-09-07 10:45:56",
"action": "user_login",
"detail": "User 't@x.de' angemeldet (Trusted Device)",
"user": "t@x.de"
},
{
"timestamp": "2026-09-07 10:45:56",
"action": "user_login",
"detail": "User 't@x.de' angemeldet (mit MFA, Ger\u00e4t f\u00fcr 7 Tage gespeichert)",
"user": "t@x.de"
},
{
"timestamp": "2026-09-07 10:44:24",
"action": "user_login",
"detail": "User 't@x.de' angemeldet (Trusted Device)",
"user": "t@x.de"
},
{
"timestamp": "2026-09-07 10:44:24",
"action": "user_login",
"detail": "User 't@x.de' angemeldet (mit MFA, Ger\u00e4t f\u00fcr 7 Tage gespeichert)",
"user": "t@x.de"
},
{
"timestamp": "2026-09-04 10:38:34",
"action": "user_login",
"detail": "User 'erik.thiele@cancom.de' angemeldet",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 17:58:05",
"action": "screen_deleted",
"detail": "Screen 'xxx' an Standort 'test' gel\u00f6scht",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 17:57:39",
"action": "screen_created",
"detail": "Screen 'xxx' an Standort 'test' angelegt",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 17:56:56",
"action": "background_uploaded",
"detail": "Hintergrundbild f\u00fcr Standort 'test' hochgeladen",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 17:53:02",
"action": "customer_added",
"detail": "Kunden (WIN, IBM, Gaufer) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: casino, vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 17:44:39",
"action": "screen_created",
"detail": "Screen 'casino' an Standort 'test' angelegt",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 17:44:31",
"action": "site_deleted",
"detail": "Standort 'casino' gel\u00f6scht",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 17:44:25",
"action": "site_created",
"detail": "Standort 'casino' angelegt",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 17:41:44",
"action": "customer_added",
"detail": "Kunden (WIN, IBM) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 17:41:32",
"action": "customer_added",
"detail": "Kunden (IBM) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 17:40:55",
"action": "customer_added",
"detail": "Kunden (XXTT) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 16:10:33",
"action": "customer_added",
"detail": "Kunden (Teamshop Kuenzelsau) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 15:59:00",
"action": "customer_added",
"detail": "Kunden (Teamshop Kuenzelsau) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 15:52:39",
"action": "customer_added",
"detail": "Kunden (Teamshop Kuenzelsau, IBM) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 15:52:13",
"action": "customer_added",
"detail": "Kunden (Teamshop Kuenzelsau, IBM) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 15:52:04",
"action": "customer_added",
"detail": "Kunden (Teamshop Kuenzelsau, IBM) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 15:42:42",
"action": "customer_added",
"detail": "Kunden (Teamshop Kuenzelsau) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 15:42:25",
"action": "customer_added",
"detail": "Kunden (Teamshop Kuenzelsau) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 15:36:52",
"action": "customer_added",
"detail": "Kunden (Teamshop Kuenzelsau) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
"user": "admin"
},
{
"timestamp": "2026-09-02 15:32:23",
"action": "customer_added",
"detail": "Kunden (IBM) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 15:32:06",
"action": "customer_added",
"detail": "Kunden (IBM) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 15:31:59",
"action": "customer_added",
"detail": "Kunden (IBM) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 15:31:44",
"action": "customer_added",
"detail": "Kunden (IBM) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 15:31:31",
"action": "customer_added",
"detail": "Kunden (IBM) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 15:31:14",
"action": "customer_added",
"detail": "Kunden (IBM) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 15:30:36",
"action": "customer_added",
"detail": "Kunden (Teamshop Kuenzelsau) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 15:28:22",
"action": "customer_added",
"detail": "Kunden (Teamshop Kuenzelsau) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
"user": "admin"
},
{
"timestamp": "2026-09-02 15:26:50",
"action": "customer_added",
"detail": "Kunden (HellesLogo) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
"user": "admin"
},
{
"timestamp": "2026-09-02 15:23:36",
"action": "customer_added",
"detail": "Kunden (UploadTest) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
"user": "admin"
},
{
"timestamp": "2026-09-02 15:22:22",
"action": "customer_added",
"detail": "Kunden (ibm) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 15:21:07",
"action": "customer_added",
"detail": "Kunden (ibm) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 15:19:35",
"action": "customer_added",
"detail": "Kunden (ibm) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 15:19:21",
"action": "customer_added",
"detail": "Kunden (ibm) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 15:09:15",
"action": "customer_added",
"detail": "Kunden (ibm) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: logos, vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 15:08:35",
"action": "customer_added",
"detail": "Kunden (ibm) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: logos, vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 15:08:14",
"action": "customer_added",
"detail": "Kunden (ibm) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: logos, vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 15:07:50",
"action": "customer_added",
"detail": "Kunden (ibm, lenovo, logitech) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: logos, vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 15:02:34",
"action": "customer_added",
"detail": "Kunden (ibm, lenovo, logitech) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: logos, vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 15:02:01",
"action": "customer_added",
"detail": "Kunden (ibm, lenovo, logitech) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: logos, vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 15:01:07",
"action": "customer_added",
"detail": "Kunden (ibm, lenovo, logitech) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: logos, vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 14:35:54",
"action": "customer_added",
"detail": "Kunden (Testkunde) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
"user": "admin"
},
{
"timestamp": "2026-09-02 14:31:04",
"action": "screen_created",
"detail": "Screen 'vorstand' an Standort 'test' angelegt",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 14:30:58",
"action": "site_created",
"detail": "Standort 'test' angelegt",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 14:30:51",
"action": "user_login",
"detail": "User 'erik.thiele@cancom.de' angemeldet",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 14:29:42",
"action": "password_changed",
"detail": "Passwort f\u00fcr User 'erik.thiele@cancom.de' ge\u00e4ndert",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 14:29:34",
"action": "user_login",
"detail": "User 'erik.thiele@cancom.de' angemeldet (Passwort-\u00c4nderung erforderlich)",
"user": "erik.thiele@cancom.de"
},
{ {
"timestamp": "2026-08-29 16:20:09", "timestamp": "2026-08-29 16:20:09",
"action": "user_login", "action": "user_login",

View File

@@ -52,6 +52,19 @@
gap: 0.75rem; gap: 0.75rem;
} }
/* ─── Prio-Vorschau: reine Anzeige, keine Bedienung ─── */
.playlist-prio {
display: grid;
grid-template-columns: 28px 1fr 90px 80px;
align-items: center;
gap: 0.75rem;
opacity: 0.72;
background: var(--tblr-bg-surface-secondary, #f8f9fa);
}
.playlist-prio .playlist-name {
font-weight: 500;
}
.playlist-name { .playlist-name {
font-weight: 600; font-weight: 600;
overflow: hidden; overflow: hidden;
@@ -229,6 +242,32 @@
<!-- ═══ Tab: Playlist ═══ --> <!-- ═══ Tab: Playlist ═══ -->
<div class="tab-pane active" id="tab-playlist-{{ screen }}" role="tabpanel"> <div class="tab-pane active" id="tab-playlist-{{ screen }}" role="tabpanel">
{% if priority_preview %}
<!-- Priority-Playlist des Standorts: laeuft diesen Screens vorweg.
Reine Anzeige - Bedienung ausschliesslich unter "Priority". -->
<ul class="list-group mb-2">
{% for file in priority_preview %}
<li class="list-group-item playlist-prio">
<i class="ti ti-player-play" title="Laeuft vorweg"></i>
<span class="playlist-name">
{{ file.name }}
{% if file.schedule %}<span class="badge ms-1 {% if file.active %}bg-info text-white{% else %}bg-secondary-lt{% endif %}" title="{{ file.schedule | schedule_text }}{% if not file.active %} — laeuft gerade nicht{% endif %}">Zeit</span>{% endif %}
{% if not file.enabled %}<span class="badge bg-secondary-lt ms-1">inaktiv</span>{% endif %}
</span>
<span class="badge bg-red text-red-fg">Prio</span>
<span class="playlist-size">
{% if file.type == "url" %}{{ file.size }}{% else %}{{ file.size }} KB{% endif %}
</span>
</li>
{% endfor %}
</ul>
<p class="text-muted small mb-3">
Diese Einträge kommen aus der
<a href="{{ url_for('admin_priority', site=current_site) }}">Priority-Playlist</a>
des Standorts und laufen allen Screens vorweg. Bearbeiten nur dort.
</p>
{% endif %}
<ul class="list-group mb-3" id="playlist-{{ screen }}"> <ul class="list-group mb-3" id="playlist-{{ screen }}">
{% for file in media_files[screen] %} {% for file in media_files[screen] %}
<li class="list-group-item playlist-row" <li class="list-group-item playlist-row"
@@ -251,7 +290,7 @@
title="URL: {{ file.name }}{% if file.zoom %} (Zoom: {{ file.zoom }}x){% endif %}" title="URL: {{ file.name }}{% if file.zoom %} (Zoom: {{ file.zoom }}x){% endif %}"
{% endif %}> {% endif %}>
{{ file.name }}{% if file.type == "url" and file.zoom != 1.0 %} <strong style="color: #DA002D;">[{{ file.zoom }}x]</strong>{% endif %}{% if file.type == "url" %}{% if file.get('passthrough', False) %} <i class="ti ti-link" style="color:#6c757d;" title="Query-Parameter werden übergeben"></i>{% else %} <i class="ti ti-link-off" style="color:#adb5bd;" title="Query-Parameter werden nicht übergeben"></i>{% endif %}{% endif %} {{ file.name }}{% if file.type == "url" and file.zoom != 1.0 %} <strong style="color: #DA002D;">[{{ file.zoom }}x]</strong>{% endif %}{% if file.type == "url" %}{% if file.get('passthrough', False) %} <i class="ti ti-link" style="color:#6c757d;" title="Query-Parameter werden übergeben"></i>{% else %} <i class="ti ti-link-off" style="color:#adb5bd;" title="Query-Parameter werden nicht übergeben"></i>{% endif %}{% endif %}
{% if file.schedule %}<span class="badge bg-info text-white ms-1" title="{{ file.schedule | schedule_text }}">Zeit</span>{% endif %} {% if file.schedule %}<span class="badge ms-1 {% if file.active %}bg-info text-white{% else %}bg-secondary-lt{% endif %}" title="{{ file.schedule | schedule_text }}{% if not file.active %} — laeuft gerade nicht{% endif %}">Zeit</span>{% endif %}
</span> </span>
<!-- Typ-Badge --> <!-- Typ-Badge -->

View File

@@ -121,6 +121,9 @@
<div class="card mt-4"> <div class="card mt-4">
<div class="card-header"> <div class="card-header">
<h3 class="card-title"><i class="ti ti-history me-1"></i>Verlauf</h3> <h3 class="card-title"><i class="ti ti-history me-1"></i>Verlauf</h3>
{% if history_total %}
<span class="card-subtitle text-secondary small ms-auto">{{ history_from }}–{{ history_to }} von {{ history_total }}</span>
{% endif %}
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
<div class="list-group list-group-flush"> <div class="list-group list-group-flush">
@@ -171,6 +174,29 @@
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
{% if history_pages > 1 %}
<div class="card-footer d-flex align-items-center">
<p class="m-0 text-secondary small">Seite {{ history_page }} von {{ history_pages }}</p>
<ul class="pagination m-0 ms-auto">
<li class="page-item {% if history_page <= 1 %}disabled{% endif %}">
<a class="page-link" href="?page={{ history_page - 1 }}"><i class="ti ti-chevron-left"></i> zurück</a>
</li>
{# Nur ein Fenster um die aktuelle Seite zeigen, sonst wird die Leiste bei vielen Seiten zu lang #}
{% for p in range(1, history_pages + 1) %}
{% if p == 1 or p == history_pages or (p >= history_page - 2 and p <= history_page + 2) %}
<li class="page-item {% if p == history_page %}active{% endif %}">
<a class="page-link" href="?page={{ p }}">{{ p }}</a>
</li>
{% elif p == history_page - 3 or p == history_page + 3 %}
<li class="page-item disabled"><span class="page-link">…</span></li>
{% endif %}
{% endfor %}
<li class="page-item {% if history_page >= history_pages %}disabled{% endif %}">
<a class="page-link" href="?page={{ history_page + 1 }}">weiter <i class="ti ti-chevron-right"></i></a>
</li>
</ul>
</div>
{% endif %}
</div> </div>
</div> </div>

View File

@@ -104,8 +104,8 @@
<li><strong>Medien hochladen:</strong> Im Tab "Medien" eine Datei auswählen und hochladen. Unterstützt werden Bilder (JPG, PNG) und Videos (MP4).</li> <li><strong>Medien hochladen:</strong> Im Tab "Medien" eine Datei auswählen und hochladen. Unterstützt werden Bilder (JPG, PNG) und Videos (MP4).</li>
<li><strong>URL hinzufügen:</strong> Im Tab "Playlist" eine URL eingeben und optional den Zoom-Faktor anpassen.</li> <li><strong>URL hinzufügen:</strong> Im Tab "Playlist" eine URL eingeben und optional den Zoom-Faktor anpassen.</li>
<li><strong>Sortieren:</strong> Per Drag & Drop die Reihenfolge der Playlist-Einträge ändern (Ziehgriff links).</li> <li><strong>Sortieren:</strong> Per Drag & Drop die Reihenfolge der Playlist-Einträge ändern (Ziehgriff links).</li>
<li><strong>Deaktivieren:</strong> Mit der Checkbox einzelne Einträge temporär ausblenden.</li> <li><strong>Deaktivieren:</strong> Mit der Checkbox einzelne Einträge temporär ausblenden. Die Checkbox ist ein reiner Schalter und hat mit der Zeitsteuerung nichts zu tun: Bei einem zeitgesteuerten Eintrag bleibt der Haken gesetzt, auch wenn der Eintrag gerade nicht läuft. Ob er läuft, zeigt das Zeit-Badge – <span class="badge bg-info text-white">Zeit</span> blau = läuft gerade, <span class="badge bg-secondary-lt">Zeit</span> grau = ausserhalb des Zeitfensters. Abgespielt wird nur, was <em>beides</em> erfüllt: Haken gesetzt und im Zeitfenster.</li>
<li><strong>Löschen:</strong> Über den roten Papierkorb-Button.</li> <li><strong>Löschen:</strong> Über den roten Papierkorb-Button. Die Datei wird dabei entfernt <em>und</em> der Eintrag aus der Playlist genommen.</li>
<li><strong>Bearbeiten:</strong> Der <em>Edit</em>-Button öffnet einen Dialog. Bei URLs lassen sich dort Adresse, Zoom, Query-Parameter-Passthrough und Zeitsteuerung ändern; bei Bildern, Videos und HTML-Seiten die Zeitsteuerung.</li> <li><strong>Bearbeiten:</strong> Der <em>Edit</em>-Button öffnet einen Dialog. Bei URLs lassen sich dort Adresse, Zoom, Query-Parameter-Passthrough und Zeitsteuerung ändern; bei Bildern, Videos und HTML-Seiten die Zeitsteuerung.</li>
<li><strong>Zeitsteuerung:</strong> Einträge können nur zu bestimmten Zeiten laufen – siehe <a href="#help-zeitsteuerung">Zeitsteuerung</a>. Ein Badge <span class="badge bg-info text-white">Zeit</span> in der Playlist-Zeile zeigt an, dass ein Eintrag zeitgesteuert ist; der Tooltip nennt die Einstellung im Klartext.</li> <li><strong>Zeitsteuerung:</strong> Einträge können nur zu bestimmten Zeiten laufen – siehe <a href="#help-zeitsteuerung">Zeitsteuerung</a>. Ein Badge <span class="badge bg-info text-white">Zeit</span> in der Playlist-Zeile zeigt an, dass ein Eintrag zeitgesteuert ist; der Tooltip nennt die Einstellung im Klartext.</li>
<li><strong>Query-Parameter-Passthrough:</strong> Per URL-Edit-Button (Stift-Icon) im Playlist-Eintrag kann pro URL gesteuert werden, ob Query-Parameter von der Player-URL an die Playlist-URL übergeben werden. Standardmäßig <strong>aus</strong> – Checkbox "Query-Parameter an diese URL übergeben" aktivieren wenn gewünscht. Ein Link-Icon in der Playlist-Zeile zeigt den Status: <i class="ti ti-link"></i> = an, <i class="ti ti-link-off"></i> = aus.</li> <li><strong>Query-Parameter-Passthrough:</strong> Per URL-Edit-Button (Stift-Icon) im Playlist-Eintrag kann pro URL gesteuert werden, ob Query-Parameter von der Player-URL an die Playlist-URL übergeben werden. Standardmäßig <strong>aus</strong> – Checkbox "Query-Parameter an diese URL übergeben" aktivieren wenn gewünscht. Ein Link-Icon in der Playlist-Zeile zeigt den Status: <i class="ti ti-link"></i> = an, <i class="ti ti-link-off"></i> = aus.</li>
@@ -170,12 +170,14 @@
<div class="card mb-3"> <div class="card mb-3">
<div class="card-header"><h3 class="card-title" id="help-priority"><i class="ti ti-player-play me-1"></i>Priority-Playlist</h3></div> <div class="card-header"><h3 class="card-title" id="help-priority"><i class="ti ti-player-play me-1"></i>Priority-Playlist</h3></div>
<div class="card-body"> <div class="card-body">
<p>Die Priority-Playlist ist <strong>global</strong> und wird auf <em>allen</em> Playern eines Standorts eingeblendet – zwischen den normalen Playlist-Einträgen.</p> <p>Die Priority-Playlist gilt <strong>pro Standort</strong>. Ihre Inhalte werden auf <em>allen</em> Screens dieses Standorts eingeblendet – zwischen den normalen Playlist-Einträgen.</p>
<ul> <ul>
<li>Aufruf über <code>/admin/&lt;standort&gt;/priority</code></li> <li>Aufruf über <code>/admin/&lt;standort&gt;/priority</code></li>
<li>Globale Medien und URLs verwalten, ebenfalls per Drag & Drop</li> <li>Medien und URLs dieses Standorts verwalten, ebenfalls per Drag & Drop</li>
<li>Priority muss in der Konfiguration aktiviert sein (<code>config.json → priority.enabled</code>)</li> <li>Priority muss für den Standort aktiviert sein (<code>config.json → sites.&lt;standort&gt;.priority.enabled</code>)</li>
<li>Die <a href="#help-zeitsteuerung">Zeitsteuerung</a> steht hier genauso zur Verfügung wie in den Screen-Playlists</li> <li>Die <a href="#help-zeitsteuerung">Zeitsteuerung</a> steht hier genauso zur Verfügung wie in den Screen-Playlists</li>
<li>Jeder Standort hat seine <strong>eigene</strong> Priority-Playlist – Inhalte eines Standorts erscheinen nie auf den Screens eines anderen.</li>
<li><strong>Vorschau in den Screen-Playlisten:</strong> Die Priority-Einträge werden zusätzlich oberhalb jeder normalen Playlist angezeigt – ausgegraut und mit rotem <span class="badge bg-red text-red-fg">Prio</span>-Badge. Das ist eine <em>reine Anzeige</em>: Sie lässt sich dort nicht sortieren, bearbeiten oder löschen. So ist beim Bearbeiten einer Screen-Playlist sofort sichtbar, was dem Screen ohnehin noch vorweg läuft, ohne dass man in den anderen Menüpunkt wechseln muss. Geändert wird weiterhin ausschliesslich auf der Priority-Seite.</li>
</ul> </ul>
</div> </div>
</div> </div>
@@ -247,6 +249,7 @@
<div class="card-header"><h3 class="card-title" id="help-willkommen"><i class="ti ti-users me-1"></i>Willkommensseite</h3></div> <div class="card-header"><h3 class="card-title" id="help-willkommen"><i class="ti ti-users me-1"></i>Willkommensseite</h3></div>
<div class="card-body"> <div class="card-body">
<p>Auf der Willkommensseite können bis zu 3 Kundennamen eingegeben werden. Die Logos werden automatisch via OpenAI + Brandfetch gesucht.</p> <p>Auf der Willkommensseite können bis zu 3 Kundennamen eingegeben werden. Die Logos werden automatisch via OpenAI + Brandfetch gesucht.</p>
<p class="text-muted small"><i class="ti ti-lock-open me-1"></i><strong>Ohne Anmeldung nutzbar:</strong> Das Formular kann auch ohne Benutzerkonto aufgerufen werden – etwa am Empfang. Wer angemeldet ist, sieht weiterhin nur die eigenen Standorte; ohne Anmeldung stehen alle zur Auswahl. Im Aktivitätsverlauf erscheinen solche Einträge als „nicht angemeldet".</p>
<ul> <ul>
<li>Aufruf über <code>/willkommen?site=&lt;standort&gt;</code> (oder Menüpunkt)</li> <li>Aufruf über <code>/willkommen?site=&lt;standort&gt;</code> (oder Menüpunkt)</li>
<li><strong>Zielscreens:</strong> Die generierte <code>welcome.html</code> wird automatisch an den Anfang der Playlist <em>jedes</em> Screens gesetzt, bei dem im Tab "Einstellungen" die Option <em>Willkommensseite hier anzeigen</em> aktiv ist. So lässt sich dieselbe Begrüßung auf mehreren Screens zeigen, ohne sie mehrfach anzulegen. Ist an einem Standort nichts eingestellt, geht sie wie bisher in die Lobby.</li> <li><strong>Zielscreens:</strong> Die generierte <code>welcome.html</code> wird automatisch an den Anfang der Playlist <em>jedes</em> Screens gesetzt, bei dem im Tab "Einstellungen" die Option <em>Willkommensseite hier anzeigen</em> aktiv ist. So lässt sich dieselbe Begrüßung auf mehreren Screens zeigen, ohne sie mehrfach anzulegen. Ist an einem Standort nichts eingestellt, geht sie wie bisher in die Lobby.</li>
@@ -307,6 +310,7 @@
<li><strong>Sparkline:</strong> Kleine Liniendiagramme zeigen den Verlauf der letzten Änderungen</li> <li><strong>Sparkline:</strong> Kleine Liniendiagramme zeigen den Verlauf der letzten Änderungen</li>
<li><strong>Trend:</strong> Pfeil nach oben/unten mit Netto-Änderung aus den letzten 30 Aktionen</li> <li><strong>Trend:</strong> Pfeil nach oben/unten mit Netto-Änderung aus den letzten 30 Aktionen</li>
<li><strong>Aktivitätsverlauf:</strong> Alle Erstell-, Lösch-, Änderungsaktionen sowie An-/Abmeldungen werden protokolliert</li> <li><strong>Aktivitätsverlauf:</strong> Alle Erstell-, Lösch-, Änderungsaktionen sowie An-/Abmeldungen werden protokolliert</li>
<li><strong>Blättern:</strong> Der Verlauf zeigt 50 Einträge je Seite; über die Leiste unter der Liste lässt sich weiter zurückblättern. Aufbewahrt werden die letzten 1000 Einträge. Statistik-Cards, Sparklines und Trendpfeile beziehen sich dabei immer auf die jüngsten 30 Aktionen und ändern sich beim Blättern nicht.</li>
</ul> </ul>
</div> </div>
</div> </div>

View File

@@ -46,7 +46,7 @@
<input class="form-check-input" type="checkbox" id="remember" name="remember" value="7"> <input class="form-check-input" type="checkbox" id="remember" name="remember" value="7">
<label class="form-check-label" for="remember"> <label class="form-check-label" for="remember">
Gerät für Gerät für
<select class="form-select form-select-sm d-inline-block w-auto" id="remember_days" onchange="document.getElementById('remember').value=this.value"> <select class="form-select form-select-sm d-inline-block w-auto" id="remember_days">
<option value="1">1 Tag</option> <option value="1">1 Tag</option>
<option value="3">3 Tage</option> <option value="3">3 Tage</option>
<option value="7" selected>7 Tage</option> <option value="7" selected>7 Tage</option>
@@ -61,10 +61,13 @@
var cb = document.getElementById('remember'); var cb = document.getElementById('remember');
var sel = document.getElementById('remember_days'); var sel = document.getElementById('remember_days');
cb.addEventListener('change', function() { cb.addEventListener('change', function() {
if (this.checked) this.value = sel.value; this.value = sel.value;
}); });
// Wer im Dropdown eine Dauer waehlt, will das Geraet merken:
// Checkbox automatisch mit aktivieren.
sel.addEventListener('change', function() { sel.addEventListener('change', function() {
if (cb.checked) cb.value = this.value; cb.checked = true;
cb.value = this.value;
}); });
})(); })();
</script> </script>

View File

@@ -472,7 +472,8 @@ function playNext() {
} }
// Datei aus dem Medienverzeichnis // Datei aus dem Medienverzeichnis
const src = entry.isPrio ? `/media/priority/${item.name}` : `/media/${site}/${basePath}/${item.name}`; // ?v=<mtime> erlaubt dem Browser, die Datei ohne Rueckfrage aus dem Cache zu nehmen
const src = `/media/${site}/${basePath}/${item.name}` + (item.v ? `?v=${item.v}` : "");
if (isVideo(item)) { if (isVideo(item)) {
vid.style.display = "block"; vid.style.display = "block";
vid.src = src; vid.src = src;

View File

@@ -147,13 +147,13 @@
{% if file.type == "image" %} {% if file.type == "image" %}
data-bs-toggle="tooltip" data-bs-toggle="tooltip"
data-bs-html="true" data-bs-html="true"
title="<img src='/media/priority/{{ file.name }}' style='max-width: 200px; max-height: 150px; border: none; background: white; padding: 2px;' alt='Vorschau'>" title="<img src='/media/{{ current_site }}/priority/{{ file.name }}' style='max-width: 200px; max-height: 150px; border: none; background: white; padding: 2px;' alt='Vorschau'>"
{% elif file.type == "url" %} {% elif file.type == "url" %}
data-bs-toggle="tooltip" data-bs-toggle="tooltip"
title="URL: {{ file.name }}{% if file.zoom %} (Zoom: {{ file.zoom }}x){% endif %}" title="URL: {{ file.name }}{% if file.zoom %} (Zoom: {{ file.zoom }}x){% endif %}"
{% endif %}> {% endif %}>
{{ file.name }}{% if file.type == "url" and file.zoom != 1.0 %} <strong style="color: #DA002D;">[{{ file.zoom }}x]</strong>{% endif %}{% if file.type == "url" %}{% if file.get('passthrough', False) %} <i class="ti ti-link" style="color:#6c757d;" title="Query-Parameter werden übergeben"></i>{% else %} <i class="ti ti-link-off" style="color:#adb5bd;" title="Query-Parameter werden nicht übergeben"></i>{% endif %}{% endif %} {{ file.name }}{% if file.type == "url" and file.zoom != 1.0 %} <strong style="color: #DA002D;">[{{ file.zoom }}x]</strong>{% endif %}{% if file.type == "url" %}{% if file.get('passthrough', False) %} <i class="ti ti-link" style="color:#6c757d;" title="Query-Parameter werden übergeben"></i>{% else %} <i class="ti ti-link-off" style="color:#adb5bd;" title="Query-Parameter werden nicht übergeben"></i>{% endif %}{% endif %}
{% if file.schedule %}<span class="badge bg-info text-white ms-1" title="{{ file.schedule | schedule_text }}">Zeit</span>{% endif %} {% if file.schedule %}<span class="badge ms-1 {% if file.active %}bg-info text-white{% else %}bg-secondary-lt{% endif %}" title="{{ file.schedule | schedule_text }}{% if not file.active %} — laeuft gerade nicht{% endif %}">Zeit</span>{% endif %}
</span> </span>
{% if file.type == "image" %} {% if file.type == "image" %}

View File

@@ -19,7 +19,6 @@
"trusted_devices": [] "trusted_devices": []
}, },
"erik.thiele@cancom.de": { "erik.thiele@cancom.de": {
"password_hash": "scrypt:32768:8:1$D8XYprsEYMO5EPrG$2829b18f77bbd7d9f0cbb945b0bdbc32e16d7889f8711881f82dd35accd26ddb7c31d3fa85e3b89d19d6d1f0b92d8d18765e1c82044188cab8fd0db2d35a0d15",
"role": "admin", "role": "admin",
"sites": [], "sites": [],
"must_change_password": false, "must_change_password": false,
@@ -27,24 +26,13 @@
"last_name": "Thiele", "last_name": "Thiele",
"department": "CSO", "department": "CSO",
"notes": "Standort Stuttgart", "notes": "Standort Stuttgart",
"mfa_secret": "LR5DYI3SQPMW5XWKO33HPETHY5TPOWRG",
"mfa_enabled": true,
"mfa_recovery_codes": [
"7BBD93",
"EB1D3B",
"BEE291",
"D471D5",
"D43C00",
"75DBAC",
"8B00CB",
"7AF72D"
],
"trusted_devices": [ "trusted_devices": [
{ {
"token": "5kMgj4JPxd2IHuzV5MagAAcYyi_DBImyGimBSvZUNVg", "token": "5kMgj4JPxd2IHuzV5MagAAcYyi_DBImyGimBSvZUNVg",
"expires": "2026-07-24T15:40:28.955130" "expires": "2026-07-24T15:40:28.955130"
} }
] ],
"password_hash": "scrypt:32768:8:1$9k9WAwHOnYTdKY4Q$869e9bec6a838c212543a228edc7a516933b37b9494bb1d10424f18adef3ea132d4c340ab939ca023c22ab0d4def13ad64c44a648f64ce519c2097adc0537f92"
}, },
"test1@test.de": { "test1@test.de": {
"password_hash": "scrypt:32768:8:1$35kBhL79TJyVA3Rz$98b2e269db5ae96c9285323a274a0cc5b199fb45078e7530b5e72cdb9b96d5b522aa18fd06ce0f801bd19ec68ed323ffa04ce484cfb849eba73b9260ca6a50e1", "password_hash": "scrypt:32768:8:1$35kBhL79TJyVA3Rz$98b2e269db5ae96c9285323a274a0cc5b199fb45078e7530b5e72cdb9b96d5b522aa18fd06ce0f801bd19ec68ed323ffa04ce484cfb849eba73b9260ca6a50e1",