Compare commits

...

31 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
Erik Thiele
1dbb88538d chore: Version auf 6.2.2 heben
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 17:59:53 +02:00
Erik Thiele
f8d9539959 feat: Erklaerungssatz unter die Kundenfelder ziehen
"Das System sucht automatisch nach den Kundenlogos und erstellt eine
angepasste Willkommensseite." stand bisher ganz unten bei den Angaben
zu Zielscreens und Markdown. Der Satz beschreibt aber, was mit den
eingegebenen Kundennamen passiert, und steht jetzt direkt darunter -
gefolgt vom Hinweis zum Logo-Upload und der bestehenden Trennlinie vor
"Anzeigen bis". Aus zwei Absaetzen wurde ein Satz.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 17:50:16 +02:00
Erik Thiele
7e2f9a404d feat: Trennlinie zwischen Ansprechpartner und Logo-Upload
Je Kundenspalte eine <hr class="mt-4 mb-3"> zwischen dem
Ansprechpartner-Feld und "Logo hochladen" - gleiche Gestaltung wie die
uebrigen Abschnittstrenner der Seite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 17:45:19 +02:00
Erik Thiele
7f9a1632b3 feat: gewaehlte Logo-Datei im Formular anzeigen
Ein Neuladen der Willkommensseite - etwa beim Wechsel des Standorts -
leert alle <input type="file">. Die zuvor gewaehlte Datei ist damit weg,
ohne dass es auffaellt, und das Absenden erzeugt eine Willkommensseite
ohne das hochgeladene Logo.

Unter jedem Datei-Feld steht jetzt "Keine Datei gewählt" bzw. nach der
Auswahl Name und Groesse der Datei. Damit ist vor dem Absenden
erkennbar, ob wirklich etwas angehaengt ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 16:01:35 +02:00
Erik Thiele
556ccb211a fix: Zugriffsprotokoll von gunicorn aktivieren
Ohne --access-logfile protokolliert gunicorn keine Requests. Im Log war
dadurch nicht erkennbar, ob eine Anfrage den Container ueberhaupt
erreicht - beim Nachstellen eines Upload-Problems fehlte damit die
grundlegendste Information.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 15:57:46 +02:00
Erik Thiele
8f0e702cca fix: PYTHONUNBUFFERED im Container setzen
Ohne die Variable puffert Python stdout blockweise, sobald es kein
Terminal ist. Saemtliche print()-Ausgaben der App - darunter die
Diagnosezeilen zum Logo-Upload - erschienen dadurch nicht in
"docker compose logs", sondern blieben im Puffer haengen. In der
gesamten Log-Historie des Containers stand kein einziger APP.PY-Eintrag.
Tracebacks waren sichtbar, weil Flask sie ueber stderr ausgibt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 15:55:23 +02:00
Erik Thiele
fe2846dfa6 chore: Upload-Diagnose im Willkommensseiten-Formular
Protokolliert je Kundenfeld, ob eine Datei angekommen ist. Ein
fehlendes enctype im Formular - etwa wenn der Browser eine aeltere
Fassung der Seite aus dem Cache liefert - sieht serverseitig sonst
genauso aus wie "kein Logo hochgeladen", und der Unterschied war aus
dem Log nicht erkennbar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 15:37:44 +02:00
Erik Thiele
a25566aab4 fix: Logo nicht komplett wegradieren, wenn es dem Hintergrund aehnelt
Ein helles Logo auf hellem Grund liegt innerhalb der Farbtoleranz des
Flood Fill. Der Fuellvorgang lief vom Rand in das Logo hinein und
loeschte es mit - das Ergebnis war ein vollstaendig transparentes PNG,
auf der Willkommensseite also gar kein Logo. Ohne jede Rueckmeldung:
die bisherige Pruefung schlug nur an, wenn *nichts* entfernt wurde, nicht
wenn alles entfernt wurde.

remove_logo_background() legt jetzt vor dem Eingriff eine Kopie an und
misst danach ueber das Alpha-Histogramm, wie viel sichtbar geblieben
ist. Unter 2% gilt der Versuch als misslungen: das unveraenderte Bild
wird zurueckgegeben und die Oberflaeche nennt den Grund samt Auswegen
(transparentes PNG hochladen oder "Hintergrund behalten" ankreuzen).

Getestet mit Logos von dunkelrot bis reinweiss auf weissem Grund sowie
einem dunklen Logo auf schwarzem Grund.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 15:27:06 +02:00
18 changed files with 1254 additions and 170 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>`).
@@ -124,7 +125,7 @@ Standorte (sites) sind die oberste Organisationsebene und gruppieren Screens.
- **Logo-Upload**: Pro Kunde kann im Willkommensseiten-Formular eine Bilddatei hochgeladen werden (`logo_file_1..3`); ein Upload hat Vorrang vor `search_customer_logo`. Ablage als `media/<site>/logo_<slot>.png`, Auslieferung über `/media/<site>/logos/<datei>` (die Route liest aus dem Standort-Verzeichnis).
- **Keine neuen Unterordner unter `media/<site>/` anlegen.** `admin()` behandelt jeden Unterordner dort als Screen und persistiert ihn ueber `get_screen_config()` in der Konfiguration - ein Ordner `logos` erschien dadurch als Phantom-Screen, bekam eine `welcome.html` und liess sich ueber die Admin-UI bespielen und loeschen. Deshalb liegen Logos wie das Hintergrundbild als Dateien direkt im Standort-Verzeichnis. Der Name `logos` ist in der Screen-Erkennung zusaetzlich ausgenommen, damit Altbestaende nicht wieder auftauchen. Die URL traegt einen `?v=<timestamp>`, damit Player kein altes Logo aus dem Cache zeigen.
- Nach dem Freistellen schneidet `trim_transparent_border()` die durchsichtigen Raender weg. Das ist kein Schoenheitsdetail: `.customer-logo` skaliert mit `object-fit: contain` auf 120px Hoehe, ein breiter Rand zaehlt dabei mit und laesst ein hochgeladenes Logo deutlich kleiner erscheinen als ein eng beschnittenes Brandfetch-Logo (gemessen: 30px statt 120px). Der Schnitt greift auch bei bereits transparenten PNGs, aber nicht bei abgewaehltem Freistellen - dort gibt es keinen transparenten Rand.
- `remove_logo_background()` arbeitet mit Pillow (Flood Fill von den Raendern, weiche Kante). **Bewusst kein ML-Modell** wie rembg: der Container laeuft mit read-only Root-FS ohne Modell-Cache. Flood Fill statt globalem Farbvergleich ist wesentlich - sonst verschwindet auch Weiss *innerhalb* des Logos. Bereits transparente Bilder werden unveraendert durchgereicht; bei unruhigem Bildrand bleibt das Bild unangetastet und die Funktion gibt einen Hinweis zurueck.
- `remove_logo_background()` arbeitet mit Pillow (Flood Fill von den Raendern, weiche Kante). **Bewusst kein ML-Modell** wie rembg: der Container laeuft mit read-only Root-FS ohne Modell-Cache. Flood Fill statt globalem Farbvergleich ist wesentlich - sonst verschwindet auch Weiss *innerhalb* des Logos. Bereits transparente Bilder werden unveraendert durchgereicht; bei unruhigem Bildrand bleibt das Bild unangetastet und die Funktion gibt einen Hinweis zurueck. **Ebenso, wenn nach dem Fuellen weniger als 2% sichtbar waeren**: ein helles Logo auf hellem Grund liegt innerhalb der Toleranz, der Flood Fill frisst es mit auf und das Ergebnis waere ein leeres PNG - auf der Willkommensseite also gar kein Logo. In dem Fall wird die vor dem Eingriff angelegte Kopie zurueckgegeben.
- `welcome_data` in der Site-Config wird **feldweise** gespeichert (Slot 1-3, auch leere), nicht nur die erfolgreichen Kunden - sonst verschiebt sich die Zuordnung von Name, Logo und Ansprechpartner beim naechsten Aufruf. Ein zuvor hochgeladenes Logo wird beim erneuten Generieren wiederverwendet, solange der Kundenname im selben Feld unveraendert ist.
- **Der Schalter `welcome_enabled` wirkt sofort**: `update_screen` ruft bei einer Aenderung `sync_welcome_for_screen()`, das die vorhandene `welcome.html` in den Screen kopiert und vorne einfuegt bzw. beim Abschalten Eintrag und Datei entfernt. Kein erneutes Generieren noetig - die Seite ist je Standort identisch. Die Funktion aendert nur das uebergebene config-Dict, gespeichert wird vom Aufrufer.
- `save_welcome_page` legt zusaetzlich eine Standort-Kopie unter `media/<site>/welcome.html` an, die **nie geloescht** wird. Ohne sie waere nach dem Abschalten aller Screens keine Quelle mehr da und der Schalter wuerde stillschweigend nichts tun.
@@ -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

@@ -14,7 +14,12 @@ RUN addgroup --system --gid 1000 app && \
USER app
ENV HOME=/tmp
# Ohne das puffert Python stdout blockweise, weil es kein Terminal ist -
# die print()-Ausgaben der App tauchen dann nicht in "docker compose logs" auf.
ENV PYTHONUNBUFFERED=1
EXPOSE 5005
CMD ["gunicorn", "-b", "0.0.0.0:5005", "-w", "4", "app:app"]
# --access-logfile - schreibt jeden Request nach stdout. Ohne das protokolliert
# gunicorn keine Zugriffe, und man sieht nicht, ob eine Anfrage ueberhaupt ankommt.
CMD ["gunicorn", "-b", "0.0.0.0:5005", "-w", "4", "--access-logfile", "-", "app:app"]

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`.
@@ -300,7 +328,7 @@ Maximal 3 Kunden eingeben → Logos werden via OpenAI + Brandfetch gesucht → `
**Logo-Upload:** Findet die automatische Suche kein Logo (oder passt es nicht), kann pro Kunde eine Bilddatei hochgeladen werden. Ein Upload hat Vorrang vor der Suche. Die Datei wird als `media/<site>/logo_<slot>.png` abgelegt und über `/media/<site>/logos/<datei>` ausgeliefert. **Bewusst als Datei im Standort-Verzeichnis, nicht in einem Unterordner:** `admin()` behandelt jeden Unterordner von `media/<site>/` als Screen und schreibt ihn dauerhaft in die Konfiguration – ein Ordner `logos` würde also als Phantom-Screen in der Oberfläche auftauchen. Das Hintergrundbild liegt aus demselben Grund als `media/<site>/background.*`. Der Ordnername `logos` ist zusätzlich reserviert und wird bei der Screen-Erkennung übersprungen.
**Freistellen und Zuschneiden:** Beim Upload wird der Hintergrund automatisch entfernt, damit das Logo nicht in einem weissen Kasten auf dem Hintergrundbild steht. Anschliessend werden die durchsichtigen Ränder weggeschnitten (`trim_transparent_border`). Ohne diesen Schnitt erscheint ein hochgeladenes Logo kleiner als ein automatisch gefundenes: die Willkommensseite skaliert mit `object-fit: contain` auf eine feste Höhe von 120px, und ein breiter Rand zählt dabei mit – Brandfetch-Logos sind bereits eng beschnitten. Verfahren: Flood Fill von den Bildrändern her (bewusst kein globaler Farbvergleich – so bleibt Weiss *innerhalb* des Logos erhalten), weiche Kante gegen Treppeneffekte, Speicherung immer als PNG. Bereits transparente Bilder werden unverändert übernommen; ist der Bildrand nicht einfarbig, bleibt das Bild unangetastet und die Oberfläche weist darauf hin. Mit der Checkbox „Hintergrund behalten" lässt sich das Freistellen pro Kunde abschalten. Umgesetzt mit Pillow – bewusst ohne ML-Modell, da der Container mit read-only Root-FS läuft und kein Modell-Cache zur Verfügung steht.
**Freistellen und Zuschneiden:** Beim Upload wird der Hintergrund automatisch entfernt, damit das Logo nicht in einem weissen Kasten auf dem Hintergrundbild steht. Anschliessend werden die durchsichtigen Ränder weggeschnitten (`trim_transparent_border`). Ohne diesen Schnitt erscheint ein hochgeladenes Logo kleiner als ein automatisch gefundenes: die Willkommensseite skaliert mit `object-fit: contain` auf eine feste Höhe von 120px, und ein breiter Rand zählt dabei mit – Brandfetch-Logos sind bereits eng beschnitten. Verfahren: Flood Fill von den Bildrändern her (bewusst kein globaler Farbvergleich – so bleibt Weiss *innerhalb* des Logos erhalten), weiche Kante gegen Treppeneffekte, Speicherung immer als PNG. Bereits transparente Bilder werden unverändert übernommen; ist der Bildrand nicht einfarbig, bleibt das Bild unangetastet und die Oberfläche weist darauf hin. Dasselbe gilt, wenn beim Freistellen fast nichts übrig bliebe – ein helles Logo auf hellem Grund liegt innerhalb der Farbtoleranz und würde sonst vollständig verschwinden, was auf der Willkommensseite als „kein Logo" ankäme. Mit der Checkbox „Hintergrund behalten" lässt sich das Freistellen pro Kunde abschalten. Umgesetzt mit Pillow – bewusst ohne ML-Modell, da der Container mit read-only Root-FS läuft und kein Modell-Cache zur Verfügung steht.
**Anzeigen bis:** Optionales Datum im Formular. Ist es gesetzt, bekommt der Eintrag eine Zeitsteuerung (`daily 00:00–23:59` mit `valid_until`) und verschwindet danach auf allen Screens von selbst.
@@ -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`

309
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.1"
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:
try:
expires = datetime.fromisoformat(d["expires"])
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:
try:
expires = datetime.fromisoformat(d["expires"])
if expires > now:
# 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
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")
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:
config = load_config()
sites = get_accessible_sites(config, current_user)
site = sites[0] if sites else ""
# 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()
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(),
@@ -959,6 +1024,11 @@ def add_customer():
if not name:
continue
datei = request.files.get(f"logo_file_{i + 1}")
# Eindeutig protokollieren, ob ueberhaupt eine Datei ankam -
# ein fehlendes enctype im Formular (etwa aus dem Browser-Cache)
# sieht sonst genauso aus wie "kein Logo hochgeladen".
print(f"🔍 APP.PY - Slot {i + 1} ({name}): Upload = "
f"{datei.filename if datei and datei.filename else 'keiner'}")
if datei and datei.filename:
url, hinweis = generate_welcome_page.save_uploaded_logo(
datei, site, i + 1,
@@ -1019,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:
@@ -1138,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)
# -------------------------------------------------
@@ -1205,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"
@@ -1221,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 = []
@@ -1235,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 = []
@@ -1248,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),
@@ -1291,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 = {
@@ -1377,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)
@@ -1404,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)
@@ -1442,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
})
@@ -1466,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
})
@@ -1493,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,
@@ -1516,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",
@@ -1714,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))
@@ -1785,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)
@@ -1804,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)
@@ -1842,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", [])
@@ -1863,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
@@ -1902,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", [])
@@ -2005,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))
@@ -2055,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,
@@ -2073,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,
@@ -2106,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

@@ -112,6 +112,10 @@ def remove_logo_background(image, tolerance=LOGO_TOLERANCE):
return img, ("Der Bildrand ist nicht einfarbig – das Logo wurde unverändert "
"übernommen. Am besten ein PNG mit transparentem Hintergrund hochladen.")
# Vor dem Eingriff sichern: ist das Logo farblich zu nah am Hintergrund,
# frisst der Flood Fill es mit auf und wir muessen zurueckfallen koennen.
original = img.copy()
# Flood Fill von allen Randpixeln aus
besucht = bytearray(w * h)
queue = deque()
@@ -155,7 +159,20 @@ def remove_logo_background(image, tolerance=LOGO_TOLERANCE):
if d < tolerance * 2:
px[x, y] = (r, g, b, int(255 * max(0.0, (d - tolerance) / tolerance)))
if not any(px[x, y][3] == 0 for x in range(0, w, max(1, w // 50)) for y in (0, h - 1)):
# Wie viel ist uebrig geblieben? Ein helles Logo auf hellem Grund liegt
# innerhalb der Toleranz und wuerde komplett mit entfernt - das Ergebnis
# waere ein leeres Bild und auf der Willkommensseite gar kein Logo.
histogramm = img.getchannel("A").histogram()
sichtbar = sum(histogramm[1:])
anteil = sichtbar / float(w * h)
if anteil < 0.02:
return original, ("Das Logo ist farblich zu nah am Hintergrund – beim Freistellen "
"wäre fast nichts übrig geblieben. Es wurde deshalb unverändert "
"übernommen. Am besten ein PNG mit transparentem Hintergrund "
"hochladen oder die Option \u201eHintergrund behalten\u201c ankreuzen.")
if anteil > 0.999:
return img, "Es wurde kein zusammenhängender Hintergrund gefunden."
return img, None

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

@@ -153,9 +153,13 @@
<textarea class="form-control" id="contact_{{ i + 1 }}" name="contact_{{ i + 1 }}" rows="4"
placeholder="{{ slots[i][1] }}">{{ contacts[i] if contacts else '' }}</textarea>
<label class="form-label mt-2" for="logo_file_{{ i + 1 }}">Logo hochladen (optional)</label>
<input type="file" class="form-control" id="logo_file_{{ i + 1 }}"
name="logo_file_{{ i + 1 }}" accept="image/*">
<hr class="mt-4 mb-3">
<label class="form-label" for="logo_file_{{ i + 1 }}">Logo hochladen (optional)</label>
<input type="file" class="form-control logo-datei" id="logo_file_{{ i + 1 }}"
name="logo_file_{{ i + 1 }}" accept="image/*"
data-status="logo_status_{{ i + 1 }}">
<div class="form-hint" id="logo_status_{{ i + 1 }}">Keine Datei gewählt</div>
<div class="form-check mt-1">
<input class="form-check-input" type="checkbox"
id="logo_keep_bg_{{ i + 1 }}" name="logo_keep_bg_{{ i + 1 }}">
@@ -165,6 +169,10 @@
{% endfor %}
</div>
<div class="text-muted mb-2">
Das System sucht automatisch nach den Kundenlogos und erstellt eine angepasste Willkommensseite.
</div>
<div class="text-muted small mb-3">
<i class="ti ti-info-circle me-1"></i>Ein hochgeladenes Logo hat Vorrang vor der automatischen Suche.
Der Hintergrund wird automatisch freigestellt, damit das Logo auf der Willkommensseite nicht in einem
@@ -182,8 +190,6 @@
</div>
<div class="mb-3 text-muted">
<p class="mb-1">Das System sucht automatisch nach den Kundenlogos</p>
<p class="mb-0">und erstellt eine angepasste Willkommensseite.</p>
<p class="mb-1">Sie erscheint auf allen Screens dieses Standorts, bei denen im Tab „Einstellungen" die Option <em>Willkommensseite hier anzeigen</em> aktiv ist{% if welcome_screens %} – aktuell: <strong>{{ welcome_screens | join(', ') }}</strong>{% else %} – <strong>aktuell keiner</strong>{% endif %}.</p>
<p class="mb-0 small"><i class="ti ti-markdown me-1"></i>Ansprechpartner unterstützen <strong>fett</strong>, <em>kursiv</em>, <code>Code</code>, Aufzählungen (<code>- </code>) sowie Zeilenumbrüche.</p>
</div>
@@ -247,5 +253,23 @@
{% include "_footer.html" %}
</div> <!-- /page -->
<script>
/* Gewaehlte Logo-Datei sichtbar machen. Ein Neuladen der Seite - etwa beim
Wechsel des Standorts - leert Datei-Felder, ohne dass man es bemerkt. */
document.querySelectorAll(".logo-datei").forEach(function (feld) {
var status = document.getElementById(feld.dataset.status);
feld.addEventListener("change", function () {
if (feld.files && feld.files.length) {
var kb = Math.round(feld.files[0].size / 1024);
status.textContent = "Gewählt: " + feld.files[0].name + " (" + kb + " KB)";
status.className = "form-hint text-success";
} else {
status.textContent = "Keine Datei gewählt";
status.className = "form-hint";
}
});
});
</script>
</body>
</html>

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,13 +249,14 @@
<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>
<li><strong>Der Schalter wirkt sofort:</strong> Schaltest du ihn bei einem weiteren Screen ein, erscheint eine bereits erzeugte Willkommensseite dort umgehend in der Playlist – ohne dass du sie neu generieren musst. Schaltest du ihn aus, verschwindet sie dort wieder.</li>
<li><strong>Anzeigen bis:</strong> Optionales Datum im Formular. Ist es gesetzt, verschwindet die Willkommensseite nach diesem Tag auf allen Screens von selbst – kein manuelles Aufräumen nach dem Besuch. Ohne Angabe bleibt sie stehen.</li>
<li><strong>Logo hochladen:</strong> Findet die automatische Suche kein Logo oder passt es nicht, kann pro Kunde eine eigene Bilddatei hochgeladen werden. Ein Upload hat immer Vorrang vor der automatischen Suche.</li>
<li><strong>Freistellen:</strong> Der Hintergrund eines hochgeladenen Logos wird automatisch entfernt, damit es auf der Willkommensseite nicht in einem weissen Kasten steht. Das gelingt am besten bei einem Logo auf einfarbigem Grund – ein PNG mit bereits transparentem Hintergrund wird unverändert übernommen. Bei unruhigem Bildrand bleibt das Bild unangetastet und es erscheint ein Hinweis. Mit „Hintergrund behalten" lässt sich das Freistellen abschalten.</li>
<li><strong>Freistellen:</strong> Der Hintergrund eines hochgeladenen Logos wird automatisch entfernt, damit es auf der Willkommensseite nicht in einem weissen Kasten steht. Das gelingt am besten bei einem Logo auf einfarbigem Grund – ein PNG mit bereits transparentem Hintergrund wird unverändert übernommen. Bei unruhigem Bildrand – oder wenn das Logo farblich zu nah am Hintergrund liegt und beim Freistellen verschwinden würde – bleibt das Bild unangetastet und es erscheint ein Hinweis. Mit „Hintergrund behalten" lässt sich das Freistellen abschalten.</li>
<li><strong>Grösse:</strong> Nach dem Freistellen werden leere Ränder weggeschnitten, damit das hochgeladene Logo auf der Willkommensseite genauso gross erscheint wie ein automatisch gefundenes.</li>
<li>Ein einmal hochgeladenes Logo bleibt beim erneuten Generieren erhalten, solange der Kundenname im selben Feld steht.</li>
<li>Pro Standort kann ein eigenes Hintergrundbild hochgeladen werden (Tab "Medien" im Standort-Dashboard)</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",