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>/priority` | Priority-Playlist als separate Seite |
| `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 /playlist/<site>/<screen>/hash` | Playlist-Checksumme für Auto-Reload + zeichnet Heartbeat auf (Online-Status) |
| `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.
- 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.
- `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.
- 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).
- `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>`).
@@ -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.
- 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
- `media/` and media file extensions are gitignored.
- `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.
- 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`.
- 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.

View File

@@ -9,8 +9,8 @@ Browserbasiertes Digital-Signage-System für interne Info-Screens.
- Browserbasierter Player (Kiosk-Modus, Chromium, Firefox)
- Bilder, Videos (MP4/H.264), HTML-Seiten, URL-Playlist-Einträge
- **Multi-Standort**: Standorte (sites) gruppieren Screens
- **Priority-Playlist**: globale Inhalte wirken auf alle Player
- **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
- **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** (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
- Auto-Reload bei Playlist-Änderungen
- 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
- **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)
- **Admin Dashboard**: `/admin/dashboard` – Statistiken (Sites, Screens, User, Admins, Superuser) mit Sparkline-Charts + Trendanzeige, Aktivitätsverlauf
- **Aktivitätsverlauf**: Alle Erstell-/Lösch-/Änderungsaktionen sowie Login/Logout werden in `history.json` protokolliert und im Dashboard angezeigt
- **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 (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)
- **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.
@@ -53,7 +53,7 @@ Flask App (Server)
│ │ │ ├── bild.jpg
│ │ │ ├── video.mp4
│ │ │ └── welcome.html
│ └── priority/
│ │ └── priority/
└── generate_welcome_page.py
```
@@ -75,10 +75,13 @@ signage/
├── history.json # Aktivitätsverlauf (Dashboard)
├── media/
│ ├── <site>/
│ │ ├── lobby/
│ │ ├── lobby/ # Screen-Ordner (jeder Unterordner = ein Screen!)
│ │ ├── casino/
│ │ └── videosysteme/
│ └── priority/
│ │ ├── videosysteme/
│ │ ├── 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/
│ ├── admin.html # Admin-Dashboard (Übersicht)
│ ├── 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).
**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
@@ -185,7 +207,7 @@ Server läuft auf `http://localhost:5005` (gunicorn mit 4 Workern, non-root User
| `GET /login` | Admin-Login |
| `GET /logout` | Ausloggen |
| `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>/priority` | Priority-Playlist (separate Seite) |
| `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 |
| `GET /player/<site>/<screen>` | Player-Ansicht |
| `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) |
| `GET /change-password` | Passwort ändern (First-Login / nach Admin-Reset) |
| `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/edit/<email>` | User bearbeiten speichern (Admin) |
| `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 /admin/help` | Hilfeseite zur Bedienung |
| `GET /customer` | Alias für `/willkommen` |
| `GET /media/<site>/background/<filename>` | Hintergrundbild der Willkommensseite |
### Heartbeat / Online-Status
@@ -263,6 +287,8 @@ http://localhost:5005/admin/dashboard
- 5 Statistik-Cards (Standorte, Screens, User, Admins, Superuser) mit ApexCharts-Sparkline + Trendanzeige
- Aktivitätsverlauf (History) als Liste mit Zeitstempel, Aktion und User
- 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
@@ -292,6 +318,8 @@ Beispiel:
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`).
**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
}
}
},
"priority": {
"enabled": true,
"playlist": [...]
}
}
```
@@ -436,6 +460,32 @@ OPENAI_API_KEY=sk-...
| `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` |
## 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>
## Playlist-Einträge
@@ -446,7 +496,7 @@ Ein Eintrag ist entweder ein String (Dateiname oder URL, Altbestand) oder ein Di
{
"url": "https://example.com",
"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
"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.
- `_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.
- **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)
- `_in_validity_range(schedule, day)` – optionalen Gültigkeitszeitraum prüfen
- `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`)
- `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
@@ -535,6 +591,7 @@ Wird über `localStorage("signage-theme")` persistiert. Umschalt-Button im Heade
## Sicherheit
- 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)
- 8 Recovery-Codes pro User, einmalig bei Aktivierung angezeigt
- 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 json
import copy
import hashlib
import re
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
USERS_FILE = os.path.join(BASE_DIR, "users.json") # User-Datenbank
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"}
app = Flask(__name__)
app.secret_key = "CHANGE_THIS_SECRET!!!"
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
def inject_globals():
"""Stellt globale Template-Variablen für Header/Footer bereit."""
@@ -66,7 +90,7 @@ def inject_globals():
from flask_login import current_user
if current_user.is_authenticated:
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:
site_list = get_site_list(cfg)
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):
history = []
history.insert(0, entry) # Neueste zuerst
# Maximal 200 Einträge behalten
history = history[:200]
# Maximal HISTORY_MAX Einträge behalten
history = history[:HISTORY_MAX]
with open(HISTORY_FILE, "w") as f:
json.dump(history, f, indent=2)
def get_history(limit=50):
"""Gibt die letzten history-Einträge zurück."""
def _load_history():
"""Liest den kompletten Verlauf; bei defekter Datei eine leere Liste."""
if not os.path.exists(HISTORY_FILE):
return []
try:
with open(HISTORY_FILE) as f:
history = json.load(f)
return history[:limit]
return json.load(f)
except (json.JSONDecodeError, OSError):
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):
"""Holt die Konfiguration für einen bestimmten Screen.
Erzeugt automatisch fehlende Dict-Ebenen (sites → site → screens → screen)."""
@@ -380,16 +413,38 @@ def is_item_active(item):
# -------------------------------------------------
# Priority-Playlist-Helfer
# -------------------------------------------------
def load_priority_files():
"""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.
PRIORITY_DIRNAME = "priority"
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."""
cfg = load_config()
prio_dir = os.path.join(MEDIA_DIR, "priority")
prio_dir = priority_dir(site)
os.makedirs(prio_dir, exist_ok=True)
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
for item in prio_playlist:
@@ -402,7 +457,8 @@ def load_priority_files():
"type": "url",
"size": "URL",
"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,
"schedule": item.get("schedule") if isinstance(item, dict) else None
})
@@ -425,7 +481,8 @@ def load_priority_files():
"name": item_name,
"type": ftype,
"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
})
@@ -687,29 +744,29 @@ def login():
trust_token = request.cookies.get("mfa_trust")
if trust_token:
now = datetime.now()
# Abgelaufene Einträge bereinigen
active = [d for d in user.trusted_devices
if d.get("token") != trust_token or
datetime.fromisoformat(d["expires"]) > now]
changed = len(active) != len(user.trusted_devices)
treffer = None
aktiv = []
for d in user.trusted_devices:
if d.get("token") == trust_token:
try:
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
login_user(user)
add_history_entry("user_login", f"User '{email}' angemeldet (Trusted Device)", email)
if user.must_change_password:
return redirect("/change-password")
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_password_ok"] = True
return redirect("/mfa/verify")
@@ -784,7 +841,7 @@ def mfa_verify():
resp.set_cookie("mfa_trust", token, max_age=days * 86400,
path="/",
httponly=True, samesite="Lax",
secure=False)
secure=request.is_secure)
return resp
except (ValueError, TypeError):
pass
@@ -840,11 +897,11 @@ def mfa_setup():
save_users(users_data)
add_history_entry("mfa_enabled", f"User '{email}' hat MFA aktiviert", 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/")
return render_template("mfa_setup.html", error="Code ungültig. Bitte versuche es erneut.",
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/")
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",
email=email, secret=secret, qr_data=qr_b64,
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/")
@@ -896,7 +953,6 @@ def mfa_disable():
# -------------------------------------------------
@app.route("/willkommen", methods=["GET", "POST"])
@app.route("/customer", methods=["GET", "POST"])
@login_required
def add_customer():
"""
Formular für die Willkommensseite:
@@ -914,16 +970,25 @@ def add_customer():
logo_hinweise = []
site = request.args.get("site") or request.form.get("site") or ""
# Fallback auf ersten für den User zugänglichen Standort
if not site:
# Diese Seite ist bewusst ohne Login erreichbar: Am Empfang soll eine
# Begruessung auch ohne Benutzerkonto erstellt werden koennen. Ein
# angemeldeter User sieht weiterhin nur seine freigegebenen Standorte.
config = load_config()
sites = get_accessible_sites(config, current_user)
site = sites[0] if sites else ""
if current_user.is_authenticated:
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
if site and not current_user.can_access_site(site):
# Fallback auf den ersten zugaenglichen Standort
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)
remember_site(site)
if request.method == "POST":
customer_names = [
request.form.get("customer_name_1", "").strip(),
@@ -1024,7 +1089,7 @@ def add_customer():
"contacts": list(contacts)
}
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 "
f"– angezeigt auf: {', '.join(targets)}")
if valid_until:
@@ -1143,18 +1208,41 @@ def api_add_customer():
# -------------------------------------------------
# Medien ausliefern (statische Dateien)
# -------------------------------------------------
@app.route("/media/priority/<path:filename>")
def media_priority(filename):
"""Liefert globale Priority-Medien aus media/priority/ aus."""
path = os.path.join(MEDIA_DIR, "priority")
return send_from_directory(path, filename)
# Traegt die URL ein ?v=<mtime>, darf der Player die Datei ein Jahr lang ohne
# Rueckfrage aus dem Cache nehmen: eine geaenderte Datei bekommt eine neue URL.
# Ohne ?v bleibt es beim Flask-Standard (no-cache + ETag), sonst saehe ein
# unversionierter Aufruf nach dem Ersetzen einer Datei weiter die alte Fassung.
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>")
def media(site, screen, filename):
"""Liefert Medien-Dateien für einen bestimmten Screen aus."""
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):
url_data = normalize_url(item)
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
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 = {
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("._"):
continue
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_cfg = config.get("priority", {})
priority_cfg = get_priority_config(config, site)
if priority_cfg.get("enabled", False):
prio_files = []
@@ -1240,12 +1331,14 @@ def player(site, screen):
if is_url(item):
url_data = normalize_url(item)
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
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):
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:
prio_files = []
@@ -1253,8 +1346,10 @@ def player(site, screen):
"player.html",
site=site,
screen=screen,
normal_files=[item for item in normal_files if item.get("enabled", True)],
prio_files=[item for item in prio_files if item.get("enabled", True)],
normal_files=[item for item in normal_files
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),
newsticker_text=screen_cfg.get("newsticker_text", ""),
newsticker_enabled=screen_cfg.get("newsticker_enabled", False),
@@ -1296,7 +1391,7 @@ def playlist_hash(site, screen):
screen_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 []
relevant = {
@@ -1382,6 +1477,7 @@ def admin(site):
Admin-Dashboard für einen Standort.
Zeigt alle Screens als Cards mit Tabler-Tabs (Playlist, Einstellungen, Medien).
"""
remember_site(site)
cfg = load_config()
site_list = get_accessible_sites(cfg, current_user)
@@ -1409,15 +1505,17 @@ def admin(site):
config_screens = site_cfg.setdefault("screens", {})
# Ordner im Medienverzeichnis = Screens.
# "logos" ist reserviert: aeltere Versionen legten dort hochgeladene
# Kundenlogos ab, und ein solcher Ordner wuerde sonst dauerhaft als Screen
# in der Konfiguration landen.
# Reservierte Namen: "logos" (hochgeladene Kundenlogos) und "priority"
# (Priority-Medien des Standorts). Solche Ordner wuerden sonst dauerhaft als
# 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):
for entry in os.listdir(site_media_base):
entry_path = os.path.join(site_media_base, entry)
if (os.path.isdir(entry_path)
and not entry.startswith("._")
and entry != generate_welcome_page.LOGO_URL_PREFIX):
and entry not in reserviert):
known_screens.add(entry)
# Screens aus Config ergänzen (auch ohne Ordner)
@@ -1447,7 +1545,8 @@ def admin(site):
"type": "url",
"size": "URL",
"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,
"schedule": schedule
})
@@ -1471,7 +1570,8 @@ def admin(site):
"name": item_name,
"type": ftype,
"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
})
@@ -1498,12 +1598,21 @@ def admin(site):
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)
# 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(
"admin.html",
site_list=site_list,
current_site=current_site,
screens=screens,
media_files=media_files,
priority_preview=priority_preview,
screen_status=screen_status,
client_info=client_info,
version=APP_VERSION,
@@ -1521,9 +1630,10 @@ def admin(site):
@site_access_required
def admin_priority(site):
"""Zeigt die Priority-Playlist-Seite mit Tabs (Playlist + Medien)."""
remember_site(site)
cfg = load_config()
site_list = get_accessible_sites(cfg, current_user)
priority_files = load_priority_files()
priority_files = load_priority_files(site)
return render_template(
"priority.html",
@@ -1719,7 +1829,8 @@ def upload(site, screen):
filename = secure_filename(file.filename)
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:
file.save(os.path.join(MEDIA_DIR, site, screen, filename))
@@ -1790,7 +1901,7 @@ def add_url(site, screen):
config = load_config()
if screen == "priority":
config.setdefault("priority", {}).setdefault("playlist", []).append(url_data)
get_priority_config(config, site).setdefault("playlist", []).append(url_data)
else:
cfg = get_screen_config(config, site, screen)
cfg.setdefault("playlist", []).append(url_data)
@@ -1809,20 +1920,17 @@ def delete_file(site, screen, filename):
cfg = load_config()
if screen == "priority":
path = os.path.join(MEDIA_DIR, "priority", filename)
playlist = cfg.get("priority", {}).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)
)]
path = os.path.join(priority_dir(site), filename)
playlist = get_priority_config(cfg, site).get("playlist", [])
else:
path = os.path.join(MEDIA_DIR, site, screen, filename)
screens_cfg = cfg.get("sites", {}).get(site, {}).get("screens", {})
playlist = screens_cfg.get(screen, {}).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)
)]
# Ueber playlist_item_name vergleichen: Datei-Eintraege stehen als
# {"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):
os.remove(path)
@@ -1847,7 +1955,7 @@ def save_playlist(site, screen):
enabled_map = data.get("enabled", {})
if screen == "priority":
old_playlist = cfg.get("priority", {}).get("playlist", [])
old_playlist = get_priority_config(cfg, site).get("playlist", [])
else:
screens_cfg = cfg.get("sites", {}).get(site, {}).get("screens", {})
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)})
if screen == "priority":
cfg.setdefault("priority", {})["playlist"] = new_playlist
get_priority_config(cfg, site)["playlist"] = new_playlist
else:
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()
if screen == "priority":
playlist = config.setdefault("priority", {}).setdefault("playlist", [])
playlist = get_priority_config(config, site).setdefault("playlist", [])
else:
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")
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)
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
def build_sparkline(entries, creates, deletes):
seq = list(reversed(entries))
@@ -2060,9 +2179,14 @@ def admin_dashboard():
user_trend=user_trend,
admin_trend=admin_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),
current_site=get_site_list(cfg)[0] if get_site_list(cfg) else "",
current_site=current_site_for(cfg, current_user),
version=APP_VERSION,
year=datetime.now().year,
hostname=os.uname().nodename,
@@ -2078,7 +2202,7 @@ def admin_help():
return render_template(
"help.html",
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,
year=datetime.now().year,
hostname=os.uname().nodename,
@@ -2111,7 +2235,7 @@ def admin_users():
"user_list.html",
users=sorted(users_list, key=lambda u: (u["last_name"].lower(), u["first_name"].lower())),
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,
year=datetime.now().year,
hostname=os.uname().nodename,

View File

@@ -1,25 +1,6 @@
{
"server_url": "http://localhost:5005",
"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": {
"stuttgart": {
"screens": {
@@ -76,11 +57,13 @@
"enabled": true
}
],
"newsticker_text": "Herzlich willkommen bei der CANCOM - wir w\u00fcnschen ihnen einen sch\u00f6nen Tag",
"newsticker_enabled": true,
"newsticker_text": "",
"newsticker_enabled": false,
"custom_url": "https://www.meteoblue.com/en/meteotv/d7b0fd",
"custom_url_label": "Wetter",
"custom_url_enabled": false
"custom_url_enabled": false,
"stay_on_first": false,
"welcome_enabled": false
},
"videosysteme": {
"interval": 20,
@@ -102,6 +85,25 @@
"https://cdn.brandfetch.io/www.ibm.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": {
@@ -152,6 +154,98 @@
"logo_urls": [
"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",
"action": "user_login",

View File

@@ -52,6 +52,19 @@
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 {
font-weight: 600;
overflow: hidden;
@@ -229,6 +242,32 @@
<!-- ═══ Tab: Playlist ═══ -->
<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 }}">
{% for file in media_files[screen] %}
<li class="list-group-item playlist-row"
@@ -251,7 +290,7 @@
title="URL: {{ file.name }}{% if file.zoom %} (Zoom: {{ file.zoom }}x){% 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>
<!-- Typ-Badge -->

View File

@@ -121,6 +121,9 @@
<div class="card mt-4">
<div class="card-header">
<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 class="card-body p-0">
<div class="list-group list-group-flush">
@@ -171,6 +174,29 @@
{% endfor %}
</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>

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>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>Deaktivieren:</strong> Mit der Checkbox einzelne Einträge temporär ausblenden.</li>
<li><strong>Löschen:</strong> Über den roten Papierkorb-Button.</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. 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>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>
@@ -170,12 +170,14 @@
<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-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>
<li>Aufruf über <code>/admin/&lt;standort&gt;/priority</code></li>
<li>Globale Medien und URLs verwalten, ebenfalls per Drag & Drop</li>
<li>Priority muss in der Konfiguration aktiviert sein (<code>config.json → priority.enabled</code>)</li>
<li>Medien und URLs dieses Standorts verwalten, ebenfalls per Drag & Drop</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>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>
</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-body">
<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>
<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>
@@ -307,6 +310,7 @@
<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>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>
</div>
</div>

View File

@@ -46,7 +46,7 @@
<input class="form-check-input" type="checkbox" id="remember" name="remember" value="7">
<label class="form-check-label" for="remember">
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="3">3 Tage</option>
<option value="7" selected>7 Tage</option>
@@ -61,10 +61,13 @@
var cb = document.getElementById('remember');
var sel = document.getElementById('remember_days');
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() {
if (cb.checked) cb.value = this.value;
cb.checked = true;
cb.value = this.value;
});
})();
</script>

View File

@@ -472,7 +472,8 @@ function playNext() {
}
// 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)) {
vid.style.display = "block";
vid.src = src;

View File

@@ -147,13 +147,13 @@
{% if file.type == "image" %}
data-bs-toggle="tooltip"
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" %}
data-bs-toggle="tooltip"
title="URL: {{ file.name }}{% if file.zoom %} (Zoom: {{ file.zoom }}x){% 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>
{% if file.type == "image" %}

View File

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