Compare commits
33 Commits
9dae841461
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f754c7f0a3 | ||
|
|
efcbaac5a4 | ||
|
|
96355f6897 | ||
|
|
2c72f48e28 | ||
|
|
302811fe29 | ||
|
|
a09375cb31 | ||
|
|
ac5bf8f739 | ||
|
|
e3afda9a8f | ||
|
|
20055c3b74 | ||
|
|
40ee841553 | ||
|
|
3a2ff20815 | ||
|
|
9d317ee592 | ||
|
|
cbadf096fd | ||
|
|
ccfd90d4b5 | ||
|
|
03ae5bf4ad | ||
|
|
aeab158e47 | ||
|
|
c76a917a6d | ||
|
|
c56006c18d | ||
|
|
ac343cef67 | ||
|
|
af308535fe | ||
|
|
f275615f3c | ||
|
|
d2938f87f3 | ||
|
|
a9cbe42df9 | ||
|
|
1dbb88538d | ||
|
|
f8d9539959 | ||
|
|
7e2f9a404d | ||
|
|
7f9a1632b3 | ||
|
|
556ccb211a | ||
|
|
8f0e702cca | ||
|
|
fe2846dfa6 | ||
|
|
a25566aab4 | ||
|
|
a3b9fe59b7 | ||
|
|
8de8a116bb |
24
AGENTS.md
24
AGENTS.md
@@ -29,7 +29,7 @@ Standorte (sites) sind die oberste Organisationsebene und gruppieren Screens.
|
|||||||
| `GET /admin/<site>` | Admin-Dashboard für einen Standort (`@site_access_required`) |
|
| `GET /admin/<site>` | Admin-Dashboard für einen Standort (`@site_access_required`) |
|
||||||
| `GET /admin/<site>/priority` | Priority-Playlist als separate Seite |
|
| `GET /admin/<site>/priority` | Priority-Playlist als separate Seite |
|
||||||
| `GET /media/<site>/<screen>/<file>` | Medien-Datei ausliefern |
|
| `GET /media/<site>/<screen>/<file>` | Medien-Datei ausliefern |
|
||||||
| `GET /media/priority/<file>` | Priority-Medien (global) |
|
| `GET /media/<site>/priority/<file>` | Priority-Medien des Standorts |
|
||||||
| `GET /media/<site>/background/<filename>` | Hintergrundbild der Willkommensseite |
|
| `GET /media/<site>/background/<filename>` | Hintergrundbild der Willkommensseite |
|
||||||
| `GET /playlist/<site>/<screen>/hash` | Playlist-Checksumme für Auto-Reload + zeichnet Heartbeat auf (Online-Status) |
|
| `GET /playlist/<site>/<screen>/hash` | Playlist-Checksumme für Auto-Reload + zeichnet Heartbeat auf (Online-Status) |
|
||||||
| `GET /willkommen?site=<site>` | Willkommensseite für Standort generieren |
|
| `GET /willkommen?site=<site>` | Willkommensseite für Standort generieren |
|
||||||
@@ -109,10 +109,11 @@ Standorte (sites) sind die oberste Organisationsebene und gruppieren Screens.
|
|||||||
- `GET /admin` requires login; redirects to first accessible site for the user.
|
- `GET /admin` requires login; redirects to first accessible site for the user.
|
||||||
- URL playlist items are stored as dicts like `{"url": "https://...", "zoom": 0.8}` and the zoom value must survive save/reorder flows. File items may also be dicts (`{"name": "bild.jpg", ...}`); `save_playlist` copies existing dicts, so extra keys like `schedule` survive reordering.
|
- URL playlist items are stored as dicts like `{"url": "https://...", "zoom": 0.8}` and the zoom value must survive save/reorder flows. File items may also be dicts (`{"name": "bild.jpg", ...}`); `save_playlist` copies existing dicts, so extra keys like `schedule` survive reordering.
|
||||||
- `.html` items in `media/` are rendered inline as content, not in an iframe.
|
- `.html` items in `media/` are rendered inline as content, not in an iframe.
|
||||||
- `config.priority.enabled` makes the priority playlist show on every screen.
|
- `sites.<site>.priority.enabled` makes the priority playlist show on every screen **of that site**.
|
||||||
- `POST /api/customer` generates `welcome.html` and inserts it at the front of the playlist of every target screen for the specified site. Target screens are those with `welcome_enabled`; if no screen of the site has the key at all, the old behaviour applies (`lobby` only). Optional `valid_until` (`"YYYY-MM-DD"`) attaches a schedule so the page disappears on its own.
|
- `POST /api/customer` generates `welcome.html` and inserts it at the front of the playlist of every target screen for the specified site. Target screens are those with `welcome_enabled`; if no screen of the site has the key at all, the old behaviour applies (`lobby` only). Optional `valid_until` (`"YYYY-MM-DD"`) attaches a schedule so the page disappears on its own.
|
||||||
- New standorte can be added by creating `media/<neuer-standort>/<screen>/` directories and optionally adding config to `config.json["sites"][<neuer-standort>]`.
|
- New standorte can be added by creating `media/<neuer-standort>/<screen>/` directories and optionally adding config to `config.json["sites"][<neuer-standort>]`.
|
||||||
- Priority playlist (`config.priority`) is global and affects all sites/screens.
|
- **Die Priority-Playlist gilt je Standort** (`sites.<site>.priority`), nicht global. Frueher lag sie unter `config.priority` und wirkte auf alle Standorte - dadurch lief Stuttgarter Inhalt auch in Karlsruhe. `get_priority_config(cfg, site)` liefert die Konfiguration des Standorts und uebernimmt einen globalen Altbestand beim ersten Zugriff einmalig (mit `deepcopy`, damit Aenderungen nicht zwischen Standorten durchschlagen).
|
||||||
|
- Priority-Medien liegen unter `media/<site>/priority/`, ausgeliefert ueber `/media/<site>/priority/<datei>`. Die Ordnernamen `priority` und `logos` sind in der Screen-Erkennung ausgenommen - sonst wuerden sie als Screens in der Konfiguration landen.
|
||||||
- Willkommensseite (`customer.html`) accepts up to 3 customer names; logos are fetched via OpenAI→Brandfetch and displayed in a flex row (portrait: column).
|
- Willkommensseite (`customer.html`) accepts up to 3 customer names; logos are fetched via OpenAI→Brandfetch and displayed in a flex row (portrait: column).
|
||||||
- `generate_welcome_html(customer_names, logo_urls, contacts=None, site="stuttgart", background_url=None)` takes lists for up to 3 customers; logos have equal width (280px) with fixed height 120px, text left-aligned below each logo.
|
- `generate_welcome_html(customer_names, logo_urls, contacts=None, site="stuttgart", background_url=None)` takes lists for up to 3 customers; logos have equal width (280px) with fixed height 120px, text left-aligned below each logo.
|
||||||
- Contact info supports Markdown: `**bold**`, `*italic*`, `` `code` ``, `- list items`, and line breaks (preserved as `<br>`).
|
- Contact info supports Markdown: `**bold**`, `*italic*`, `` `code` ``, `- list items`, and line breaks (preserved as `<br>`).
|
||||||
@@ -121,8 +122,10 @@ Standorte (sites) sind die oberste Organisationsebene und gruppieren Screens.
|
|||||||
- Gemeinsame HTML-Bausteine: `_header.html`, `_footer.html`, `_styles.html` (CSS-Variablen `--ccm-*`, Dark Mode, Card-Border-Radius 1rem).
|
- Gemeinsame HTML-Bausteine: `_header.html`, `_footer.html`, `_styles.html` (CSS-Variablen `--ccm-*`, Dark Mode, Card-Border-Radius 1rem).
|
||||||
- `add_customer_to_playlists` (früher `add_customer_to_lobby_playlist`) entfernt `welcome.html` als String *und* als Dict aus **allen** Screens des Standorts und fügt sie danach nur in den Zielscreens an Position 0 wieder ein – ein abgewählter Screen bleibt so nicht mit einer alten Begrüßung stehen. Eingefügt wird ein Dict, kein String, damit `enabled` und `schedule` mitgeführt werden können.
|
- `add_customer_to_playlists` (früher `add_customer_to_lobby_playlist`) entfernt `welcome.html` als String *und* als Dict aus **allen** Screens des Standorts und fügt sie danach nur in den Zielscreens an Position 0 wieder ein – ein abgewählter Screen bleibt so nicht mit einer alten Begrüßung stehen. Eingefügt wird ein Dict, kein String, damit `enabled` und `schedule` mitgeführt werden können.
|
||||||
- `welcome_target_screens(config, site)` fällt nur dann auf `lobby` zurück, wenn **kein** Screen des Standorts den Schlüssel `welcome_enabled` besitzt. Wurde er überall bewusst auf `False` gesetzt, ist die leere Liste das gewollte Ergebnis.
|
- `welcome_target_screens(config, site)` fällt nur dann auf `lobby` zurück, wenn **kein** Screen des Standorts den Schlüssel `welcome_enabled` besitzt. Wurde er überall bewusst auf `False` gesetzt, ist die leere Liste das gewollte Ergebnis.
|
||||||
- **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>/logos/logo_<slot>.png`, Auslieferung über `/media/<site>/logos/<datei>`. Die URL traegt einen `?v=<timestamp>`, damit Player kein altes Logo aus dem Cache zeigen.
|
- **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).
|
||||||
- `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.
|
- **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. **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.
|
- `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.
|
- **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.
|
- `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.
|
||||||
@@ -151,13 +154,22 @@ Standorte (sites) sind die oberste Organisationsebene und gruppieren Screens.
|
|||||||
- User-Dropdown im Header: zeigt E-Mail + Role-Badge, Menü mit Passwort ändern, MFA einrichten (mit "Aktiv"-Badge wenn MFA eingeschaltet), Userverwaltung (Admin), Abmelden.
|
- User-Dropdown im Header: zeigt E-Mail + Role-Badge, Menü mit Passwort ändern, MFA einrichten (mit "Aktiv"-Badge wenn MFA eingeschaltet), Userverwaltung (Admin), Abmelden.
|
||||||
- Info-Tab in Screen-Cards: zeigt Client-Info-Tabelle (IP, Browser, Auflösung, zuletzt gesehen) aus dem Heartbeat-JSON für den jeweiligen Screen
|
- Info-Tab in Screen-Cards: zeigt Client-Info-Tabelle (IP, Browser, Auflösung, zuletzt gesehen) aus dem Heartbeat-JSON für den jeweiligen Screen
|
||||||
|
|
||||||
|
- **Medien-Cache**: `player()` gibt jedem Datei-Eintrag `v` (mtime) mit, der Player haengt `?v=<mtime>` an. `send_media()` setzt dann `max-age` 1 Jahr + `immutable`; ohne `?v` bleibt Flasks `no-cache`. Eine ersetzte Datei bekommt so automatisch eine neue URL. Grosse Videos cacht der Browser trotzdem oft nicht (eigener Media-Cache) - dafuer braeuchte es einen Service Worker.
|
||||||
|
|
||||||
|
## Betrieb / Diagnose
|
||||||
|
|
||||||
|
- `docker compose up -d` startet nur neu und **baut nicht**. Nach Code-Aenderungen `--build` verwenden, sonst laeuft weiter das alte Image - das hat bei der Fehlersuche mehrfach zu falschen Schluessen gefuehrt.
|
||||||
|
- Das Dockerfile setzt `PYTHONUNBUFFERED=1`. Ohne das puffert Python `stdout` blockweise (kein TTY) und **keine** `print()`-Ausgabe der App erscheint in `docker compose logs`. Tracebacks waren sichtbar, weil Flask sie ueber stderr schreibt - das machte den blinden Fleck schwer erkennbar.
|
||||||
|
- gunicorn laeuft mit `--access-logfile -`, protokolliert also jeden Request. Ohne das laesst sich nicht feststellen, ob eine Anfrage den Container ueberhaupt erreicht.
|
||||||
|
- `add_customer` protokolliert je Kundenfeld `Upload = <dateiname>` bzw. `Upload = keiner`. Ein geleertes `<input type="file">` - etwa nach einem Reload der Seite - sieht serverseitig sonst genauso aus wie "kein Logo hochgeladen".
|
||||||
|
|
||||||
## Repo Quirks
|
## Repo Quirks
|
||||||
|
|
||||||
- `media/` and media file extensions are gitignored.
|
- `media/` and media file extensions are gitignored.
|
||||||
- `users.json` is NOT gitignored (tracked for initial admin setup, contains no secrets by default).
|
- `users.json` is NOT gitignored (tracked for initial admin setup, contains no secrets by default).
|
||||||
- The app has no configured tests, lint, typecheck, formatter, or CI.
|
- The app has no configured tests, lint, typecheck, formatter, or CI.
|
||||||
- Hardcoded secrets exist in tracked files; do not commit new secrets or reshuffle them casually.
|
- Hardcoded secrets exist in tracked files; do not commit new secrets or reshuffle them casually.
|
||||||
- Wichtige Helper-Funktionen in `app.py`: `load_config()`, `save_config()`, `get_site_list()`, `get_screen_config()`, `is_url()`, `normalize_url()`, `playlist_item_name()`, `is_item_active()`, `_schedule_time()`, `_window_start_date()`, `_in_validity_range()`, `load_priority_files()`, `prio_redirect()`, `get_background_url()`, `load_users()`, `save_users()`, `get_user()`, `init_user_db()`, `get_accessible_sites()`, `record_heartbeat()`, `screen_is_active()`, `get_client_info()`, `add_history_entry()`.
|
- Wichtige Helper-Funktionen in `app.py`: `load_config()`, `save_config()`, `get_site_list()`, `get_screen_config()`, `is_url()`, `normalize_url()`, `playlist_item_name()`, `is_item_active()`, `_schedule_time()`, `_window_start_date()`, `_in_validity_range()`, `load_priority_files(site)`, `get_priority_config(cfg, site)`, `priority_dir(site)`, `prio_redirect()`, `get_background_url()`, `load_users()`, `save_users()`, `get_user()`, `init_user_db()`, `get_accessible_sites()`, `record_heartbeat()`, `screen_is_active()`, `get_client_info()`, `add_history_entry()`.
|
||||||
- Zugriffs-Dekoratoren in `app.py`: `admin_required`, `site_access_required`.
|
- Zugriffs-Dekoratoren in `app.py`: `admin_required`, `site_access_required`.
|
||||||
- Hintergrundbild der Willkommensseite wird pro Standort unter `media/<site>/background.*` gespeichert; Fallback auf `static/wallpaper.png` wenn keine Datei existiert.
|
- Hintergrundbild der Willkommensseite wird pro Standort unter `media/<site>/background.*` gespeichert; Fallback auf `static/wallpaper.png` wenn keine Datei existiert.
|
||||||
- `get_background_url(site)` prüft auf benutzerdefiniertes Hintergrundbild für einen Standort.
|
- `get_background_url(site)` prüft auf benutzerdefiniertes Hintergrundbild für einen Standort.
|
||||||
|
|||||||
@@ -14,7 +14,12 @@ RUN addgroup --system --gid 1000 app && \
|
|||||||
|
|
||||||
USER app
|
USER app
|
||||||
ENV HOME=/tmp
|
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
|
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"]
|
||||||
|
|||||||
96
README.md
96
README.md
@@ -9,8 +9,8 @@ Browserbasiertes Digital-Signage-System für interne Info-Screens.
|
|||||||
- Browserbasierter Player (Kiosk-Modus, Chromium, Firefox)
|
- Browserbasierter Player (Kiosk-Modus, Chromium, Firefox)
|
||||||
- Bilder, Videos (MP4/H.264), HTML-Seiten, URL-Playlist-Einträge
|
- Bilder, Videos (MP4/H.264), HTML-Seiten, URL-Playlist-Einträge
|
||||||
- **Multi-Standort**: Standorte (sites) gruppieren Screens
|
- **Multi-Standort**: Standorte (sites) gruppieren Screens
|
||||||
- **Priority-Playlist**: globale Inhalte wirken auf alle Player
|
- **Priority-Playlist**: pro Standort; die Inhalte laufen auf allen Screens **dieses** Standorts, nicht standortübergreifend. Sie werden zusätzlich als **reine Vorschau** oberhalb jeder Screen-Playlist angezeigt (grau, mit „Prio"-Badge), damit sie beim Bearbeiten nicht übersehen werden – bedient wird weiterhin nur auf der Priority-Seite
|
||||||
- **Willkommensseite**: bis zu 3 Kundenlogos via OpenAI + Brandfetch oder **manueller Upload mit automatischem Freistellen** (transparenter Hintergrund), pro Standort konfigurierbares Hintergrundbild; erscheint auf allen Screens mit `welcome_enabled` (mehrere möglich), optional mit Ablaufdatum
|
- **Willkommensseite** (ohne Login erreichbar): bis zu 3 Kundenlogos via OpenAI + Brandfetch oder **manueller Upload mit automatischem Freistellen** (transparenter Hintergrund), pro Standort konfigurierbares Hintergrundbild; erscheint auf allen Screens mit `welcome_enabled` (mehrere möglich), optional mit Ablaufdatum
|
||||||
- Pro Screen eigene Playlist mit Drag-&-Drop-Reihenfolge
|
- Pro Screen eigene Playlist mit Drag-&-Drop-Reihenfolge
|
||||||
- Auto-Reload bei Playlist-Änderungen
|
- Auto-Reload bei Playlist-Änderungen
|
||||||
- Newsticker pro Screen
|
- Newsticker pro Screen
|
||||||
@@ -22,8 +22,8 @@ Browserbasiertes Digital-Signage-System für interne Info-Screens.
|
|||||||
- **User-Verwaltung**: Mehrere User mit Rollen (Admin / Superuser / User), E-Mail als Login, Passwort-Hashing (scrypt), Berechtigungen pro Standort
|
- **User-Verwaltung**: Mehrere User mit Rollen (Admin / Superuser / User), E-Mail als Login, Passwort-Hashing (scrypt), Berechtigungen pro Standort
|
||||||
- **Passwort-Workflow**: First-Login-Änderung, Admin-Reset mit temporärem Passwort, Notfall-Login bei fehlendem Hash
|
- **Passwort-Workflow**: First-Login-Änderung, Admin-Reset mit temporärem Passwort, Notfall-Login bei fehlendem Hash
|
||||||
- **MFA (Multi-Faktor)**: Optionale TOTP-Authentifizierung (Google Authenticator, Authy) per User aktivierbar; QR-Code-Scan + Code-Verifikation, 8 Recovery-Codes, Deaktivierung mit Passwort-Bestätigung, **Trusted Device** (Gerät für 1–30 Tage speichern, überspringt MFA beim nächsten Login)
|
- **MFA (Multi-Faktor)**: Optionale TOTP-Authentifizierung (Google Authenticator, Authy) per User aktivierbar; QR-Code-Scan + Code-Verifikation, 8 Recovery-Codes, Deaktivierung mit Passwort-Bestätigung, **Trusted Device** (Gerät für 1–30 Tage speichern, überspringt MFA beim nächsten Login)
|
||||||
- **Admin Dashboard**: `/admin/dashboard` – Statistiken (Sites, Screens, User, Admins, Superuser) mit Sparkline-Charts + Trendanzeige, Aktivitätsverlauf
|
- **Admin Dashboard**: `/admin/dashboard` – Statistiken (Sites, Screens, User, Admins, Superuser) mit Sparkline-Charts + Trendanzeige, Aktivitätsverlauf mit Seitennavigation
|
||||||
- **Aktivitätsverlauf**: Alle Erstell-/Lösch-/Änderungsaktionen sowie Login/Logout werden in `history.json` protokolliert und im Dashboard angezeigt
|
- **Aktivitätsverlauf**: Alle Erstell-/Lösch-/Änderungsaktionen sowie Login/Logout werden in `history.json` protokolliert (bis zu 1000 Einträge) und im Dashboard seitenweise angezeigt (50 je Seite)
|
||||||
- Tab-basierte Admin-UI pro Screen: Playlist, Einstellungen, Aktionen, Digital Voice Agent, Medien, Info (Tabler Tabs)
|
- Tab-basierte Admin-UI pro Screen: Playlist, Einstellungen, Aktionen, Digital Voice Agent, Medien, Info (Tabler Tabs)
|
||||||
- **Zeitsteuerung**: Pro Playlist-Eintrag (URL *und* Datei, auch in der Priority-Playlist) ein Zeitfenster festlegen – einmalig (Datum + Uhrzeit), täglich oder wöchentlich (Uhrzeit + optionaler Gültigkeitszeitraum, wöchentlich zusätzlich mit Wochentagen). Fenster über Mitternacht werden unterstützt. Ein "Zeit"-Badge in der Playlist-Zeile zeigt die Einstellung im Tooltip
|
- **Zeitsteuerung**: Pro Playlist-Eintrag (URL *und* Datei, auch in der Priority-Playlist) ein Zeitfenster festlegen – einmalig (Datum + Uhrzeit), täglich oder wöchentlich (Uhrzeit + optionaler Gültigkeitszeitraum, wöchentlich zusätzlich mit Wochentagen). Fenster über Mitternacht werden unterstützt. Ein "Zeit"-Badge in der Playlist-Zeile zeigt die Einstellung im Tooltip
|
||||||
- **Query-Parameter-Passthrough**: Per Player-URL übergebene Query-Parameter (z. B. `/player/stuttgart/lobby?customer=acme`) können pro Playlist-URL im Edit-Modal aktiviert werden – standardmäßig aus. Link-Icon in der Playlist-Zeile zeigt Status (an/aus) an.
|
- **Query-Parameter-Passthrough**: Per Player-URL übergebene Query-Parameter (z. B. `/player/stuttgart/lobby?customer=acme`) können pro Playlist-URL im Edit-Modal aktiviert werden – standardmäßig aus. Link-Icon in der Playlist-Zeile zeigt Status (an/aus) an.
|
||||||
@@ -53,7 +53,7 @@ Flask App (Server)
|
|||||||
│ │ │ ├── bild.jpg
|
│ │ │ ├── bild.jpg
|
||||||
│ │ │ ├── video.mp4
|
│ │ │ ├── video.mp4
|
||||||
│ │ │ └── welcome.html
|
│ │ │ └── welcome.html
|
||||||
│ └── priority/
|
│ │ └── priority/
|
||||||
└── generate_welcome_page.py
|
└── generate_welcome_page.py
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -75,10 +75,13 @@ signage/
|
|||||||
├── history.json # Aktivitätsverlauf (Dashboard)
|
├── history.json # Aktivitätsverlauf (Dashboard)
|
||||||
├── media/
|
├── media/
|
||||||
│ ├── <site>/
|
│ ├── <site>/
|
||||||
│ │ ├── lobby/
|
│ │ ├── lobby/ # Screen-Ordner (jeder Unterordner = ein Screen!)
|
||||||
│ │ ├── casino/
|
│ │ ├── casino/
|
||||||
│ │ └── videosysteme/
|
│ │ ├── videosysteme/
|
||||||
│ └── priority/
|
│ │ ├── background.jpg # Hintergrundbild der Willkommensseite (optional)
|
||||||
|
│ │ ├── priority/ # Priority-Medien dieses Standorts
|
||||||
|
│ │ ├── logo_1.png # hochgeladene Kundenlogos (Slot 1-3)
|
||||||
|
│ │ └── welcome.html # Standort-Kopie der Willkommensseite
|
||||||
├── templates/
|
├── templates/
|
||||||
│ ├── admin.html # Admin-Dashboard (Übersicht)
|
│ ├── admin.html # Admin-Dashboard (Übersicht)
|
||||||
│ ├── admin_dashboard.html # Admin Dashboard (Statistiken + Verlauf)
|
│ ├── admin_dashboard.html # Admin Dashboard (Statistiken + Verlauf)
|
||||||
@@ -175,6 +178,25 @@ docker compose up -d
|
|||||||
|
|
||||||
Server läuft auf `http://localhost:5005` (gunicorn mit 4 Workern, non-root User).
|
Server läuft auf `http://localhost:5005` (gunicorn mit 4 Workern, non-root User).
|
||||||
|
|
||||||
|
**Wichtig:** `docker compose up -d` startet nur neu, es baut nicht. Nach Code-Änderungen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs -f signage
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Container ist so eingestellt, dass dort alles Nötige ankommt:
|
||||||
|
|
||||||
|
- `ENV PYTHONUNBUFFERED=1` im Dockerfile – ohne das puffert Python `stdout` blockweise, sobald es kein Terminal ist, und sämtliche `print()`-Ausgaben der App bleiben unsichtbar.
|
||||||
|
- `gunicorn --access-logfile -` – protokolliert jeden Request. Ohne das ist nicht einmal erkennbar, ob eine Anfrage den Container überhaupt erreicht.
|
||||||
|
|
||||||
|
Beides fehlte anfangs und machte die Fehlersuche unnötig schwer.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Routen
|
## Routen
|
||||||
@@ -185,7 +207,7 @@ Server läuft auf `http://localhost:5005` (gunicorn mit 4 Workern, non-root User
|
|||||||
| `GET /login` | Admin-Login |
|
| `GET /login` | Admin-Login |
|
||||||
| `GET /logout` | Ausloggen |
|
| `GET /logout` | Ausloggen |
|
||||||
| `GET /admin` | Redirect zum ersten konfigurierten Standort |
|
| `GET /admin` | Redirect zum ersten konfigurierten Standort |
|
||||||
| `GET /admin/dashboard` | Admin Dashboard (Statistiken + Verlauf) |
|
| `GET /admin/dashboard` | Admin Dashboard (Statistiken + Verlauf, `?page=<n>`) |
|
||||||
| `GET /admin/<site>` | Admin-Dashboard für einen Standort |
|
| `GET /admin/<site>` | Admin-Dashboard für einen Standort |
|
||||||
| `GET /admin/<site>/priority` | Priority-Playlist (separate Seite) |
|
| `GET /admin/<site>/priority` | Priority-Playlist (separate Seite) |
|
||||||
| `POST /admin/<site>/update/<screen>` | Allgemeine Screen-Einstellungen speichern (Intervall, Newsticker, Bilder/Videos, stay_on_first) |
|
| `POST /admin/<site>/update/<screen>` | Allgemeine Screen-Einstellungen speichern (Intervall, Newsticker, Bilder/Videos, stay_on_first) |
|
||||||
@@ -204,7 +226,7 @@ Server läuft auf `http://localhost:5005` (gunicorn mit 4 Workern, non-root User
|
|||||||
| `POST /admin/<site>/delete-background` | Hintergrundbild zurücksetzen |
|
| `POST /admin/<site>/delete-background` | Hintergrundbild zurücksetzen |
|
||||||
| `GET /player/<site>/<screen>` | Player-Ansicht |
|
| `GET /player/<site>/<screen>` | Player-Ansicht |
|
||||||
| `GET /playlist/<site>/<screen>/hash` | Playlist-Checksumme (für Auto-Reload) + Heartbeat (Online-Status) |
|
| `GET /playlist/<site>/<screen>/hash` | Playlist-Checksumme (für Auto-Reload) + Heartbeat (Online-Status) |
|
||||||
| `GET /willkommen?site=<site>` | Willkommensseite-Formular (GET + POST) |
|
| `GET /willkommen?site=<site>` | Willkommensseite-Formular (GET + POST) – **ohne Login** |
|
||||||
| `POST /api/customer` | API-Endpunkt für Willkommensseite (JSON) |
|
| `POST /api/customer` | API-Endpunkt für Willkommensseite (JSON) |
|
||||||
| `GET /change-password` | Passwort ändern (First-Login / nach Admin-Reset) |
|
| `GET /change-password` | Passwort ändern (First-Login / nach Admin-Reset) |
|
||||||
| `GET /admin/users` | User-Liste (Admin) |
|
| `GET /admin/users` | User-Liste (Admin) |
|
||||||
@@ -220,8 +242,10 @@ Server läuft auf `http://localhost:5005` (gunicorn mit 4 Workern, non-root User
|
|||||||
| `POST /admin/users/reset-password/<email>` | Passwort-Reset mit temporärem Passwort (Admin) |
|
| `POST /admin/users/reset-password/<email>` | Passwort-Reset mit temporärem Passwort (Admin) |
|
||||||
| `POST /admin/users/edit/<email>` | User bearbeiten speichern (Admin) |
|
| `POST /admin/users/edit/<email>` | User bearbeiten speichern (Admin) |
|
||||||
| `GET /media/<site>/<screen>/<file>` | Medien-Datei ausliefern |
|
| `GET /media/<site>/<screen>/<file>` | Medien-Datei ausliefern |
|
||||||
| `GET /media/priority/<file>` | Priority-Medien (global) |
|
| `GET /media/<site>/priority/<file>` | Priority-Medien des Standorts |
|
||||||
| `GET /media/<site>/logos/<file>` | Manuell hochgeladenes Kundenlogo |
|
| `GET /media/<site>/logos/<file>` | Manuell hochgeladenes Kundenlogo |
|
||||||
|
| `GET /admin/help` | Hilfeseite zur Bedienung |
|
||||||
|
| `GET /customer` | Alias für `/willkommen` |
|
||||||
| `GET /media/<site>/background/<filename>` | Hintergrundbild der Willkommensseite |
|
| `GET /media/<site>/background/<filename>` | Hintergrundbild der Willkommensseite |
|
||||||
|
|
||||||
### Heartbeat / Online-Status
|
### Heartbeat / Online-Status
|
||||||
@@ -263,6 +287,8 @@ http://localhost:5005/admin/dashboard
|
|||||||
- 5 Statistik-Cards (Standorte, Screens, User, Admins, Superuser) mit ApexCharts-Sparkline + Trendanzeige
|
- 5 Statistik-Cards (Standorte, Screens, User, Admins, Superuser) mit ApexCharts-Sparkline + Trendanzeige
|
||||||
- Aktivitätsverlauf (History) als Liste mit Zeitstempel, Aktion und User
|
- Aktivitätsverlauf (History) als Liste mit Zeitstempel, Aktion und User
|
||||||
- Alle Erstell-/Lösch-/Änderungsaktionen sowie Login/Logout werden protokolliert
|
- Alle Erstell-/Lösch-/Änderungsaktionen sowie Login/Logout werden protokolliert
|
||||||
|
- **Seitennavigation**: 50 Einträge je Seite (`HISTORY_PER_PAGE`), Aufruf über `?page=<n>`; aufbewahrt werden 1000 Einträge (`HISTORY_MAX`)
|
||||||
|
- Kennzahlen und Sparklines beziehen sich immer auf die **jüngsten 30 Aktionen** und ändern sich beim Blättern bewusst nicht – sonst würden die Trendpfeile springen, obwohl sich nichts geändert hat
|
||||||
|
|
||||||
### Player-URL
|
### Player-URL
|
||||||
|
|
||||||
@@ -292,15 +318,17 @@ Beispiel:
|
|||||||
http://localhost:5005/willkommen?site=stuttgart
|
http://localhost:5005/willkommen?site=stuttgart
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Ohne Login erreichbar:** Die Seite ist bewusst nicht durch `@login_required` geschützt, damit auch Mitarbeitende ohne Benutzerkonto eine Begrüßung erstellen können. Ein angemeldeter User sieht dabei weiterhin nur seine freigegebenen Standorte; ohne Anmeldung stehen alle Standorte zur Auswahl, ein unbekannter Standort führt zu 403. Im Aktivitätsverlauf erscheinen solche Einträge als „nicht angemeldet". Die programmatische Variante `POST /api/customer` bleibt angemeldet.
|
||||||
|
|
||||||
Maximal 3 Kunden eingeben → Logos werden via OpenAI + Brandfetch gesucht → `welcome.html` wird in den Playlists aller Zielscreens vorne eingefügt. Pro Standort kann ein eigenes Hintergrundbild hochgeladen werden (Fallback auf `static/wallpaper.png`).
|
Maximal 3 Kunden eingeben → Logos werden via OpenAI + Brandfetch gesucht → `welcome.html` wird in den Playlists aller Zielscreens vorne eingefügt. Pro Standort kann ein eigenes Hintergrundbild hochgeladen werden (Fallback auf `static/wallpaper.png`).
|
||||||
|
|
||||||
**Zielscreens:** Jeder Screen mit `welcome_enabled` (Schalter „Willkommensseite hier anzeigen" im Tab Einstellungen). Besitzt kein Screen des Standorts den Schlüssel, greift das alte Verhalten und die Seite geht nur in `lobby`.
|
**Zielscreens:** Jeder Screen mit `welcome_enabled` (Schalter „Willkommensseite hier anzeigen" im Tab Einstellungen). Besitzt kein Screen des Standorts den Schlüssel, greift das alte Verhalten und die Seite geht nur in `lobby`.
|
||||||
|
|
||||||
**Der Schalter wirkt sofort:** Beim Einschalten wird eine bereits vorhandene Willkommensseite unmittelbar in die Playlist des Screens gesetzt, beim Ausschalten wieder entfernt (samt Datei) – ohne erneutes Generieren. Die Seite ist je Standort identisch, ein zweiter Handgriff wäre also nur Aufwand ohne anderes Ergebnis. Beim Generieren wird zusätzlich eine Standort-Kopie unter `media/<site>/welcome.html` abgelegt, die nie gelöscht wird; dadurch findet der Schalter auch dann noch eine Quelle, wenn die Seite zwischenzeitlich auf allen Screens abgeschaltet war.
|
**Der Schalter wirkt sofort:** Beim Einschalten wird eine bereits vorhandene Willkommensseite unmittelbar in die Playlist des Screens gesetzt, beim Ausschalten wieder entfernt (samt Datei) – ohne erneutes Generieren. Die Seite ist je Standort identisch, ein zweiter Handgriff wäre also nur Aufwand ohne anderes Ergebnis. Beim Generieren wird zusätzlich eine Standort-Kopie unter `media/<site>/welcome.html` abgelegt, die nie gelöscht wird; dadurch findet der Schalter auch dann noch eine Quelle, wenn die Seite zwischenzeitlich auf allen Screens abgeschaltet war.
|
||||||
|
|
||||||
**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>/logos/logo_<slot>.png` abgelegt und über `/media/<site>/logos/<datei>` ausgeliefert.
|
**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:** Beim Upload wird der Hintergrund automatisch entfernt, damit das Logo nicht in einem weissen Kasten auf dem Hintergrundbild steht. 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.
|
**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
|
"background_url": null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"priority": {
|
|
||||||
"enabled": true,
|
|
||||||
"playlist": [...]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -436,6 +460,32 @@ OPENAI_API_KEY=sk-...
|
|||||||
| `voice_agent_target` | string | `"overlay"` (iframe) oder `"redirect"` |
|
| `voice_agent_target` | string | `"overlay"` (iframe) oder `"redirect"` |
|
||||||
| `voice_agent_position` | string | Position des Buttons: `top-left`, `top-center`, `top-right`, `middle-left`, `middle-center`, `middle-right`, `bottom-left`, `bottom-center`, `bottom-right` |
|
| `voice_agent_position` | string | Position des Buttons: `top-left`, `top-center`, `top-right`, `middle-left`, `middle-center`, `middle-right`, `bottom-left`, `bottom-center`, `bottom-right` |
|
||||||
|
|
||||||
|
## Priority-Playlist (pro Standort)
|
||||||
|
|
||||||
|
Liegt in `config.json` unter `sites.<site>.priority`, die Medien unter `media/<site>/priority/`:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"sites": {
|
||||||
|
"stuttgart": {
|
||||||
|
"priority": {
|
||||||
|
"enabled": true, // schaltet die Priority-Playlist dieses Standorts
|
||||||
|
"playlist": [ ... ] // gleiches Eintragsformat wie eine Screen-Playlist
|
||||||
|
},
|
||||||
|
"screens": { ... }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Einträge laufen auf allen Screens **dieses** Standorts, eingestreut zwischen den normalen Playlist-Einträgen. Ein anderer Standort ist davon nicht betroffen.
|
||||||
|
|
||||||
|
**Vorschau in den Screen-Playlisten:** Ist Priority für den Standort aktiviert, erscheinen die Einträge zusätzlich über jeder Screen-Playlist – ausgegraut, mit rotem „Prio"-Badge und ohne Bedienelemente. Sie liegen bewusst in einer **eigenen `<ul>`**, damit das Speichern der Reihenfolge (`savePlaylist()`) sie nicht mit einsammelt und in die Screen-Playlist schreibt.
|
||||||
|
|
||||||
|
> Früher lag die Priority-Playlist global unter `config.priority` und wirkte auf *alle* Standorte. `get_priority_config()` übernimmt einen solchen Altbestand beim ersten Zugriff einmalig in den jeweiligen Standort.
|
||||||
|
|
||||||
|
Die Ordnernamen `priority` und `logos` sind unter `media/<site>/` **reserviert** und werden bei der Screen-Erkennung übersprungen – sonst würden sie als Screens in der Oberfläche auftauchen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
<a id="playlist-eintraege"></a>
|
<a id="playlist-eintraege"></a>
|
||||||
## Playlist-Einträge
|
## Playlist-Einträge
|
||||||
|
|
||||||
@@ -446,7 +496,7 @@ Ein Eintrag ist entweder ein String (Dateiname oder URL, Altbestand) oder ein Di
|
|||||||
{
|
{
|
||||||
"url": "https://example.com",
|
"url": "https://example.com",
|
||||||
"zoom": 1.0, // Zoom-Faktor im Player
|
"zoom": 1.0, // Zoom-Faktor im Player
|
||||||
"enabled": true, // Checkbox in der Playlist
|
"enabled": true, // Checkbox in der Playlist (nur der Schalter, nicht das Zeitfenster)
|
||||||
"passthrough_params": false, // Query-Parameter der Player-URL durchreichen
|
"passthrough_params": false, // Query-Parameter der Player-URL durchreichen
|
||||||
"schedule": { ... } // optional, siehe unten
|
"schedule": { ... } // optional, siehe unten
|
||||||
}
|
}
|
||||||
@@ -491,6 +541,9 @@ Optional pro Eintrag. Ohne `schedule` läuft der Eintrag immer (sofern `enabled`
|
|||||||
- Unlesbare oder unvollständige Zeitangaben blenden den Eintrag **nicht** aus – ein Konfigurationsfehler soll nicht dazu führen, dass stillschweigend nichts mehr läuft.
|
- Unlesbare oder unvollständige Zeitangaben blenden den Eintrag **nicht** aus – ein Konfigurationsfehler soll nicht dazu führen, dass stillschweigend nichts mehr läuft.
|
||||||
- `_schedule_time()` liest neben `"08:00"` auch volle ISO-Zeitstempel, damit Einträge aus älteren Versionen unverändert weiterlaufen.
|
- `_schedule_time()` liest neben `"08:00"` auch volle ISO-Zeitstempel, damit Einträge aus älteren Versionen unverändert weiterlaufen.
|
||||||
- Der Aktiv-Zustand fliesst in `/playlist/<site>/<screen>/hash` ein, damit der Player beim Öffnen und Schliessen eines Zeitfensters von selbst neu lädt.
|
- Der Aktiv-Zustand fliesst in `/playlist/<site>/<screen>/hash` ein, damit der Player beim Öffnen und Schliessen eines Zeitfensters von selbst neu lädt.
|
||||||
|
- **Checkbox und Zeitfenster sind getrennt:** Die Checkbox in der Playlist-Zeile zeigt ausschliesslich den gespeicherten Schalter `enabled`, nicht den momentanen Zeitfenster-Zustand. Sie bleibt also gesetzt, auch wenn ein zeitgesteuerter Eintrag gerade nicht läuft. Ob er gerade läuft, sagt das „Zeit"-Badge: blau = läuft, grau = ausserhalb des Fensters (Tooltip nennt den Grund). Abgespielt wird nur, was **beides** erfüllt – Haken gesetzt *und* im Zeitfenster.
|
||||||
|
|
||||||
|
> Bis Version 6.2.4 zeigte die Checkbox den ausgewerteten Zustand. Wurde die Playlist ausserhalb des Zeitfensters gespeichert, schrieb die Oberfläche `enabled: false` in die Konfiguration und der Eintrag war dauerhaft abgeschaltet. Der Fehler ist behoben, wirkt aber nicht rückwirkend: bereits betroffene Einträge müssen einmal von Hand wieder aktiviert werden.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -507,10 +560,14 @@ Optional pro Eintrag. Ohne `schedule` läuft der Eintrag immer (sofern `enabled`
|
|||||||
- `_window_start_date(now, start, end)` – Kalendertag, an dem das laufende Zeitfenster begann (`None` = ausserhalb)
|
- `_window_start_date(now, start, end)` – Kalendertag, an dem das laufende Zeitfenster begann (`None` = ausserhalb)
|
||||||
- `_in_validity_range(schedule, day)` – optionalen Gültigkeitszeitraum prüfen
|
- `_in_validity_range(schedule, day)` – optionalen Gültigkeitszeitraum prüfen
|
||||||
- `schedule_text(schedule)` – Jinja-Filter: Klartext einer Zeitsteuerung für Tooltips
|
- `schedule_text(schedule)` – Jinja-Filter: Klartext einer Zeitsteuerung für Tooltips
|
||||||
- `load_priority_files()` – Priority-Playlist + Dateien laden
|
- `load_priority_files(site)` – Priority-Playlist eines Standorts + Dateien laden
|
||||||
|
- `_load_history()` / `get_history(limit, offset)` / `get_history_count()` – Aktivitätsverlauf lesen, seitenweise ausschneiden, zählen
|
||||||
|
- `get_priority_config(cfg, site)` – Priority-Konfiguration des Standorts (übernimmt einmalig einen globalen Altbestand)
|
||||||
|
- `priority_dir(site)` – Pfad zu `media/<site>/priority/`
|
||||||
- `welcome_target_screens(config, site)` – Zielscreens der Willkommensseite (Fallback `lobby`)
|
- `welcome_target_screens(config, site)` – Zielscreens der Willkommensseite (Fallback `lobby`)
|
||||||
- `save_uploaded_logo(file, site, slot, freistellen=True)` – hochgeladenes Logo freistellen und als PNG ablegen
|
- `save_uploaded_logo(file, site, slot, freistellen=True)` – hochgeladenes Logo freistellen und als PNG ablegen
|
||||||
- `remove_logo_background(image)` – Hintergrund per Flood Fill entfernen, gibt `(bild, hinweis)` zurück
|
- `remove_logo_background(image)` – Hintergrund per Flood Fill entfernen, gibt `(bild, hinweis)` zurück
|
||||||
|
- `trim_transparent_border(img)` – durchsichtige Ränder wegschneiden, damit das Logo den Anzeigebereich ausfüllt
|
||||||
- `sync_welcome_for_screen(config, site, screen, enabled)` – Willkommensseite beim Umlegen des Schalters platzieren bzw. entfernen
|
- `sync_welcome_for_screen(config, site, screen, enabled)` – Willkommensseite beim Umlegen des Schalters platzieren bzw. entfernen
|
||||||
- `find_welcome_source(site, config)` – vorhandene `welcome.html` als Kopiervorlage finden
|
- `find_welcome_source(site, config)` – vorhandene `welcome.html` als Kopiervorlage finden
|
||||||
- `prio_redirect(site)` – Redirect-Pfad zur Priority-Seite
|
- `prio_redirect(site)` – Redirect-Pfad zur Priority-Seite
|
||||||
@@ -534,6 +591,7 @@ Wird über `localStorage("signage-theme")` persistiert. Umschalt-Button im Heade
|
|||||||
## Sicherheit
|
## Sicherheit
|
||||||
|
|
||||||
- Admin-Bereich per Flask-Login geschützt (E-Mail + Passwort)
|
- Admin-Bereich per Flask-Login geschützt (E-Mail + Passwort)
|
||||||
|
- **Ohne Login erreichbar** sind: Player und Medien (die Abspielgeräte melden sich nicht an) sowie das Formular der Willkommensseite. Letzteres legt Dateien an, schreibt in die Konfiguration und löst kostenpflichtige Logo-Abfragen aus – bei einer aus dem Internet erreichbaren Instanz empfiehlt sich eine Einschränkung auf das interne Netz im Reverse Proxy
|
||||||
- Optionale MFA (TOTP) per User aktivierbar (Google Authenticator, Authy)
|
- Optionale MFA (TOTP) per User aktivierbar (Google Authenticator, Authy)
|
||||||
- 8 Recovery-Codes pro User, einmalig bei Aktivierung angezeigt
|
- 8 Recovery-Codes pro User, einmalig bei Aktivierung angezeigt
|
||||||
- Passwörter gehasht (scrypt) in `users.json`
|
- Passwörter gehasht (scrypt) in `users.json`
|
||||||
|
|||||||
318
app.py
318
app.py
@@ -10,6 +10,7 @@ zugeschaltet werden, die auf allen Playern abwechselnd eingeblendet wird.
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
|
import copy
|
||||||
import hashlib
|
import hashlib
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
@@ -49,14 +50,37 @@ MEDIA_DIR = os.path.join(BASE_DIR, "media") # Medien-Dateien je Standort/S
|
|||||||
CONFIG_FILE = os.path.join(BASE_DIR, "config.json") # Persistente Konfiguration
|
CONFIG_FILE = os.path.join(BASE_DIR, "config.json") # Persistente Konfiguration
|
||||||
USERS_FILE = os.path.join(BASE_DIR, "users.json") # User-Datenbank
|
USERS_FILE = os.path.join(BASE_DIR, "users.json") # User-Datenbank
|
||||||
HISTORY_FILE = os.path.join(BASE_DIR, "history.json") # Aktivitätsverlauf
|
HISTORY_FILE = os.path.join(BASE_DIR, "history.json") # Aktivitätsverlauf
|
||||||
|
HISTORY_MAX = 1000 # so viele Einträge werden aufbewahrt
|
||||||
|
HISTORY_PER_PAGE = 50 # Einträge je Seite im Dashboard-Verlauf
|
||||||
|
|
||||||
APP_VERSION = "6.2.1"
|
APP_VERSION = "6.2.12"
|
||||||
UPLOAD_EXTENSIONS = {".jpg", ".jpeg", ".png", ".mp4"}
|
UPLOAD_EXTENSIONS = {".jpg", ".jpeg", ".png", ".mp4"}
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.secret_key = "CHANGE_THIS_SECRET!!!"
|
app.secret_key = "CHANGE_THIS_SECRET!!!"
|
||||||
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1)
|
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1)
|
||||||
|
|
||||||
|
def remember_site(site):
|
||||||
|
"""Merkt den zuletzt besuchten Standort in der Session."""
|
||||||
|
if site:
|
||||||
|
session["last_site"] = site
|
||||||
|
|
||||||
|
|
||||||
|
def current_site_for(cfg, user=None):
|
||||||
|
"""Standort für Seiten ohne eigenen Standortbezug (Dashboard, Hilfe,
|
||||||
|
User-Verwaltung, MFA).
|
||||||
|
|
||||||
|
Nimmt den zuletzt besuchten Standort, sonst den ersten *zugänglichen*.
|
||||||
|
Ohne das springt die Standortanzeige im Header auf den global ersten
|
||||||
|
Standort - und der "Übersicht"-Link führt anschliessend dorthin statt
|
||||||
|
zurück zu dem Standort, von dem man kam."""
|
||||||
|
sites = get_accessible_sites(cfg, user) if (user is not None and user.is_authenticated) else get_site_list(cfg)
|
||||||
|
letzter = session.get("last_site")
|
||||||
|
if letzter in sites:
|
||||||
|
return letzter
|
||||||
|
return sites[0] if sites else ""
|
||||||
|
|
||||||
|
|
||||||
@app.context_processor
|
@app.context_processor
|
||||||
def inject_globals():
|
def inject_globals():
|
||||||
"""Stellt globale Template-Variablen für Header/Footer bereit."""
|
"""Stellt globale Template-Variablen für Header/Footer bereit."""
|
||||||
@@ -66,7 +90,7 @@ def inject_globals():
|
|||||||
from flask_login import current_user
|
from flask_login import current_user
|
||||||
if current_user.is_authenticated:
|
if current_user.is_authenticated:
|
||||||
site_list = get_accessible_sites(cfg, current_user)
|
site_list = get_accessible_sites(cfg, current_user)
|
||||||
current_site_val = get_site_list(cfg)[0] if get_site_list(cfg) else ""
|
current_site_val = current_site_for(cfg, current_user)
|
||||||
else:
|
else:
|
||||||
site_list = get_site_list(cfg)
|
site_list = get_site_list(cfg)
|
||||||
current_site_val = site_list[0] if site_list else ""
|
current_site_val = site_list[0] if site_list else ""
|
||||||
@@ -161,24 +185,33 @@ def add_history_entry(action, detail, user_email):
|
|||||||
except (json.JSONDecodeError, OSError):
|
except (json.JSONDecodeError, OSError):
|
||||||
history = []
|
history = []
|
||||||
history.insert(0, entry) # Neueste zuerst
|
history.insert(0, entry) # Neueste zuerst
|
||||||
# Maximal 200 Einträge behalten
|
# Maximal HISTORY_MAX Einträge behalten
|
||||||
history = history[:200]
|
history = history[:HISTORY_MAX]
|
||||||
with open(HISTORY_FILE, "w") as f:
|
with open(HISTORY_FILE, "w") as f:
|
||||||
json.dump(history, f, indent=2)
|
json.dump(history, f, indent=2)
|
||||||
|
|
||||||
|
|
||||||
def get_history(limit=50):
|
def _load_history():
|
||||||
"""Gibt die letzten history-Einträge zurück."""
|
"""Liest den kompletten Verlauf; bei defekter Datei eine leere Liste."""
|
||||||
if not os.path.exists(HISTORY_FILE):
|
if not os.path.exists(HISTORY_FILE):
|
||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
with open(HISTORY_FILE) as f:
|
with open(HISTORY_FILE) as f:
|
||||||
history = json.load(f)
|
return json.load(f)
|
||||||
return history[:limit]
|
|
||||||
except (json.JSONDecodeError, OSError):
|
except (json.JSONDecodeError, OSError):
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def get_history(limit=50, offset=0):
|
||||||
|
"""Gibt einen Ausschnitt des Verlaufs zurück (neueste zuerst)."""
|
||||||
|
return _load_history()[offset:offset + limit]
|
||||||
|
|
||||||
|
|
||||||
|
def get_history_count():
|
||||||
|
"""Anzahl aller gespeicherten Verlaufseintraege (fuer die Seitennavigation)."""
|
||||||
|
return len(_load_history())
|
||||||
|
|
||||||
|
|
||||||
def get_screen_config(cfg, site, screen):
|
def get_screen_config(cfg, site, screen):
|
||||||
"""Holt die Konfiguration für einen bestimmten Screen.
|
"""Holt die Konfiguration für einen bestimmten Screen.
|
||||||
Erzeugt automatisch fehlende Dict-Ebenen (sites → site → screens → screen)."""
|
Erzeugt automatisch fehlende Dict-Ebenen (sites → site → screens → screen)."""
|
||||||
@@ -380,16 +413,38 @@ def is_item_active(item):
|
|||||||
# -------------------------------------------------
|
# -------------------------------------------------
|
||||||
# Priority-Playlist-Helfer
|
# Priority-Playlist-Helfer
|
||||||
# -------------------------------------------------
|
# -------------------------------------------------
|
||||||
def load_priority_files():
|
PRIORITY_DIRNAME = "priority"
|
||||||
"""Lädt die Priority-Playlist aus config.json plus die dazugehörigen Dateien
|
|
||||||
aus media/priority/. Gibt eine Liste von Dicts mit name/type/size/enabled zurück.
|
|
||||||
|
def priority_dir(site):
|
||||||
|
"""Verzeichnis der Priority-Medien eines Standorts: media/<site>/priority/."""
|
||||||
|
return os.path.join(MEDIA_DIR, site, PRIORITY_DIRNAME)
|
||||||
|
|
||||||
|
|
||||||
|
def get_priority_config(cfg, site):
|
||||||
|
"""Priority-Konfiguration eines Standorts (erzeugt fehlende Ebenen).
|
||||||
|
|
||||||
|
Die Priority-Playlist gilt ausschliesslich fuer den eigenen Standort.
|
||||||
|
Aeltere Konfigurationen hatten sie global unter cfg["priority"]; ist beim
|
||||||
|
Standort noch nichts hinterlegt, wird der Altbestand einmalig uebernommen."""
|
||||||
|
site_cfg = cfg.setdefault("sites", {}).setdefault(site, {})
|
||||||
|
if "priority" not in site_cfg:
|
||||||
|
alt = cfg.get("priority")
|
||||||
|
site_cfg["priority"] = copy.deepcopy(alt) if isinstance(alt, dict) else {}
|
||||||
|
return site_cfg["priority"]
|
||||||
|
|
||||||
|
|
||||||
|
def load_priority_files(site):
|
||||||
|
"""Lädt die Priority-Playlist eines Standorts aus config.json plus die
|
||||||
|
dazugehörigen Dateien aus media/<site>/priority/.
|
||||||
|
Gibt eine Liste von Dicts mit name/type/size/enabled zurück.
|
||||||
Neue Dateien im Ordner, die noch nicht in der Playlist sind, werden angehängt."""
|
Neue Dateien im Ordner, die noch nicht in der Playlist sind, werden angehängt."""
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
prio_dir = os.path.join(MEDIA_DIR, "priority")
|
prio_dir = priority_dir(site)
|
||||||
os.makedirs(prio_dir, exist_ok=True)
|
os.makedirs(prio_dir, exist_ok=True)
|
||||||
|
|
||||||
priority_files = []
|
priority_files = []
|
||||||
prio_playlist = cfg.get("priority", {}).get("playlist", [])
|
prio_playlist = get_priority_config(cfg, site).get("playlist", [])
|
||||||
|
|
||||||
# 1. Einträge aus der config.json-Playlist
|
# 1. Einträge aus der config.json-Playlist
|
||||||
for item in prio_playlist:
|
for item in prio_playlist:
|
||||||
@@ -402,7 +457,8 @@ def load_priority_files():
|
|||||||
"type": "url",
|
"type": "url",
|
||||||
"size": "URL",
|
"size": "URL",
|
||||||
"zoom": url_data.get("zoom", 1.0),
|
"zoom": url_data.get("zoom", 1.0),
|
||||||
"enabled": is_item_active(item),
|
"enabled": item.get("enabled", True) if isinstance(item, dict) else True,
|
||||||
|
"active": is_item_active(item),
|
||||||
"passthrough": item.get("passthrough_params", False) if isinstance(item, dict) else False,
|
"passthrough": item.get("passthrough_params", False) if isinstance(item, dict) else False,
|
||||||
"schedule": item.get("schedule") if isinstance(item, dict) else None
|
"schedule": item.get("schedule") if isinstance(item, dict) else None
|
||||||
})
|
})
|
||||||
@@ -425,7 +481,8 @@ def load_priority_files():
|
|||||||
"name": item_name,
|
"name": item_name,
|
||||||
"type": ftype,
|
"type": ftype,
|
||||||
"size": size,
|
"size": size,
|
||||||
"enabled": is_item_active(item),
|
"enabled": item.get("enabled", True) if isinstance(item, dict) else True,
|
||||||
|
"active": is_item_active(item),
|
||||||
"schedule": item.get("schedule") if isinstance(item, dict) else None
|
"schedule": item.get("schedule") if isinstance(item, dict) else None
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -496,8 +553,10 @@ def media_background(site, filename):
|
|||||||
@app.route("/media/<site>/logos/<path:filename>")
|
@app.route("/media/<site>/logos/<path:filename>")
|
||||||
def media_logo(site, filename):
|
def media_logo(site, filename):
|
||||||
"""Liefert ein manuell hochgeladenes Kundenlogo aus.
|
"""Liefert ein manuell hochgeladenes Kundenlogo aus.
|
||||||
Ohne Login erreichbar wie die übrigen Medien - der Player ist nicht angemeldet."""
|
Ohne Login erreichbar wie die übrigen Medien - der Player ist nicht angemeldet.
|
||||||
path = os.path.join(MEDIA_DIR, site, generate_welcome_page.LOGO_DIRNAME)
|
Die Dateien liegen direkt im Standort-Verzeichnis, damit kein Unterordner
|
||||||
|
entsteht, den admin() als Screen interpretieren wuerde."""
|
||||||
|
path = os.path.join(MEDIA_DIR, site)
|
||||||
return send_from_directory(path, filename)
|
return send_from_directory(path, filename)
|
||||||
|
|
||||||
|
|
||||||
@@ -685,29 +744,29 @@ def login():
|
|||||||
trust_token = request.cookies.get("mfa_trust")
|
trust_token = request.cookies.get("mfa_trust")
|
||||||
if trust_token:
|
if trust_token:
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
# Abgelaufene Einträge bereinigen
|
treffer = None
|
||||||
active = [d for d in user.trusted_devices
|
aktiv = []
|
||||||
if d.get("token") != trust_token or
|
|
||||||
datetime.fromisoformat(d["expires"]) > now]
|
|
||||||
changed = len(active) != len(user.trusted_devices)
|
|
||||||
for d in user.trusted_devices:
|
for d in user.trusted_devices:
|
||||||
|
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:
|
if d.get("token") == trust_token:
|
||||||
try:
|
treffer = d
|
||||||
expires = datetime.fromisoformat(d["expires"])
|
if len(aktiv) != len(user.trusted_devices):
|
||||||
if expires > now:
|
users_data = load_users()
|
||||||
# Trusted Device gültig → MFA überspringen
|
users_data.setdefault(email, {})["trusted_devices"] = aktiv
|
||||||
login_user(user)
|
|
||||||
add_history_entry("user_login", f"User '{email}' angemeldet (Trusted Device)", email)
|
|
||||||
if user.must_change_password:
|
|
||||||
return redirect("/change-password")
|
|
||||||
return redirect("/admin")
|
|
||||||
else:
|
|
||||||
pass
|
|
||||||
except (ValueError, KeyError):
|
|
||||||
pass
|
|
||||||
if changed:
|
|
||||||
users_data[email]["trusted_devices"] = active
|
|
||||||
save_users(users_data)
|
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_user"] = email
|
||||||
session["mfa_password_ok"] = True
|
session["mfa_password_ok"] = True
|
||||||
return redirect("/mfa/verify")
|
return redirect("/mfa/verify")
|
||||||
@@ -782,7 +841,7 @@ def mfa_verify():
|
|||||||
resp.set_cookie("mfa_trust", token, max_age=days * 86400,
|
resp.set_cookie("mfa_trust", token, max_age=days * 86400,
|
||||||
path="/",
|
path="/",
|
||||||
httponly=True, samesite="Lax",
|
httponly=True, samesite="Lax",
|
||||||
secure=False)
|
secure=request.is_secure)
|
||||||
return resp
|
return resp
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
@@ -838,11 +897,11 @@ def mfa_setup():
|
|||||||
save_users(users_data)
|
save_users(users_data)
|
||||||
add_history_entry("mfa_enabled", f"User '{email}' hat MFA aktiviert", email)
|
add_history_entry("mfa_enabled", f"User '{email}' hat MFA aktiviert", email)
|
||||||
return render_template("mfa_recovery.html", codes=recovery, email=email,
|
return render_template("mfa_recovery.html", codes=recovery, email=email,
|
||||||
site_list=site_list, current_site=site_list[0] if site_list else "",
|
site_list=site_list, current_site=current_site_for(cfg, current_user),
|
||||||
brand_subtitle="MFA", site_dropdown_url_prefix="/admin/")
|
brand_subtitle="MFA", site_dropdown_url_prefix="/admin/")
|
||||||
return render_template("mfa_setup.html", error="Code ungültig. Bitte versuche es erneut.",
|
return render_template("mfa_setup.html", error="Code ungültig. Bitte versuche es erneut.",
|
||||||
email=email, secret=secret, qr_data=None,
|
email=email, secret=secret, qr_data=None,
|
||||||
site_list=site_list, current_site=site_list[0] if site_list else "",
|
site_list=site_list, current_site=current_site_for(cfg, current_user),
|
||||||
brand_subtitle="MFA", site_dropdown_url_prefix="/admin/")
|
brand_subtitle="MFA", site_dropdown_url_prefix="/admin/")
|
||||||
|
|
||||||
uri = totp.provisioning_uri(name=email, issuer_name="CANCOM Signage")
|
uri = totp.provisioning_uri(name=email, issuer_name="CANCOM Signage")
|
||||||
@@ -854,7 +913,7 @@ def mfa_setup():
|
|||||||
return render_template("mfa_setup.html", error=None if not error_param else "Passwort ist falsch",
|
return render_template("mfa_setup.html", error=None if not error_param else "Passwort ist falsch",
|
||||||
email=email, secret=secret, qr_data=qr_b64,
|
email=email, secret=secret, qr_data=qr_b64,
|
||||||
recovered=recovered, disabled=disabled,
|
recovered=recovered, disabled=disabled,
|
||||||
site_list=site_list, current_site=site_list[0] if site_list else "",
|
site_list=site_list, current_site=current_site_for(cfg, current_user),
|
||||||
brand_subtitle="MFA", site_dropdown_url_prefix="/admin/")
|
brand_subtitle="MFA", site_dropdown_url_prefix="/admin/")
|
||||||
|
|
||||||
|
|
||||||
@@ -894,7 +953,6 @@ def mfa_disable():
|
|||||||
# -------------------------------------------------
|
# -------------------------------------------------
|
||||||
@app.route("/willkommen", methods=["GET", "POST"])
|
@app.route("/willkommen", methods=["GET", "POST"])
|
||||||
@app.route("/customer", methods=["GET", "POST"])
|
@app.route("/customer", methods=["GET", "POST"])
|
||||||
@login_required
|
|
||||||
def add_customer():
|
def add_customer():
|
||||||
"""
|
"""
|
||||||
Formular für die Willkommensseite:
|
Formular für die Willkommensseite:
|
||||||
@@ -912,16 +970,25 @@ def add_customer():
|
|||||||
logo_hinweise = []
|
logo_hinweise = []
|
||||||
site = request.args.get("site") or request.form.get("site") or ""
|
site = request.args.get("site") or request.form.get("site") or ""
|
||||||
|
|
||||||
# Fallback auf ersten für den User zugänglichen Standort
|
# Diese Seite ist bewusst ohne Login erreichbar: Am Empfang soll eine
|
||||||
if not site:
|
# Begruessung auch ohne Benutzerkonto erstellt werden koennen. Ein
|
||||||
config = load_config()
|
# angemeldeter User sieht weiterhin nur seine freigegebenen Standorte.
|
||||||
sites = get_accessible_sites(config, current_user)
|
config = load_config()
|
||||||
site = sites[0] if sites else ""
|
if current_user.is_authenticated:
|
||||||
|
erlaubte_sites = get_accessible_sites(config, current_user)
|
||||||
|
else:
|
||||||
|
erlaubte_sites = get_site_list(config)
|
||||||
|
|
||||||
# Prüfen, ob der User Zugriff auf den Standort hat
|
# Fallback auf den ersten zugaenglichen Standort
|
||||||
if site and not current_user.can_access_site(site):
|
if not site:
|
||||||
|
site = erlaubte_sites[0] if erlaubte_sites else ""
|
||||||
|
|
||||||
|
# Zugriff auf den Standort pruefen
|
||||||
|
if site and site not in erlaubte_sites:
|
||||||
abort(403)
|
abort(403)
|
||||||
|
|
||||||
|
remember_site(site)
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
customer_names = [
|
customer_names = [
|
||||||
request.form.get("customer_name_1", "").strip(),
|
request.form.get("customer_name_1", "").strip(),
|
||||||
@@ -950,13 +1017,18 @@ def add_customer():
|
|||||||
frueher = load_config().get("sites", {}).get(site, {}).get("welcome_data", {})
|
frueher = load_config().get("sites", {}).get(site, {}).get("welcome_data", {})
|
||||||
frueher_namen = frueher.get("names", [])
|
frueher_namen = frueher.get("names", [])
|
||||||
frueher_urls = frueher.get("logo_urls", [])
|
frueher_urls = frueher.get("logo_urls", [])
|
||||||
logo_prefix = f"/media/{site}/{generate_welcome_page.LOGO_DIRNAME}/"
|
logo_prefix = f"/media/{site}/{generate_welcome_page.LOGO_URL_PREFIX}/"
|
||||||
|
|
||||||
found_logos = [None, None, None]
|
found_logos = [None, None, None]
|
||||||
for i, name in enumerate(customer_names):
|
for i, name in enumerate(customer_names):
|
||||||
if not name:
|
if not name:
|
||||||
continue
|
continue
|
||||||
datei = request.files.get(f"logo_file_{i + 1}")
|
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:
|
if datei and datei.filename:
|
||||||
url, hinweis = generate_welcome_page.save_uploaded_logo(
|
url, hinweis = generate_welcome_page.save_uploaded_logo(
|
||||||
datei, site, i + 1,
|
datei, site, i + 1,
|
||||||
@@ -1017,7 +1089,7 @@ def add_customer():
|
|||||||
"contacts": list(contacts)
|
"contacts": list(contacts)
|
||||||
}
|
}
|
||||||
save_config(config)
|
save_config(config)
|
||||||
add_history_entry("customer_added", f"Kunden ({', '.join(valid_names)}) zur Willkommensseite an Standort '{site}' hinzugefügt (Screens: {', '.join(targets)})", current_user.email)
|
add_history_entry("customer_added", f"Kunden ({', '.join(valid_names)}) zur Willkommensseite an Standort '{site}' hinzugefügt (Screens: {', '.join(targets)})", current_user.email if current_user.is_authenticated else "nicht angemeldet")
|
||||||
success = (f"✅ Willkommensseite mit {len(valid_names)} Kunden erstellt "
|
success = (f"✅ Willkommensseite mit {len(valid_names)} Kunden erstellt "
|
||||||
f"– angezeigt auf: {', '.join(targets)}")
|
f"– angezeigt auf: {', '.join(targets)}")
|
||||||
if valid_until:
|
if valid_until:
|
||||||
@@ -1136,18 +1208,41 @@ def api_add_customer():
|
|||||||
# -------------------------------------------------
|
# -------------------------------------------------
|
||||||
# Medien ausliefern (statische Dateien)
|
# Medien ausliefern (statische Dateien)
|
||||||
# -------------------------------------------------
|
# -------------------------------------------------
|
||||||
@app.route("/media/priority/<path:filename>")
|
# Traegt die URL ein ?v=<mtime>, darf der Player die Datei ein Jahr lang ohne
|
||||||
def media_priority(filename):
|
# Rueckfrage aus dem Cache nehmen: eine geaenderte Datei bekommt eine neue URL.
|
||||||
"""Liefert globale Priority-Medien aus media/priority/ aus."""
|
# Ohne ?v bleibt es beim Flask-Standard (no-cache + ETag), sonst saehe ein
|
||||||
path = os.path.join(MEDIA_DIR, "priority")
|
# unversionierter Aufruf nach dem Ersetzen einer Datei weiter die alte Fassung.
|
||||||
return send_from_directory(path, filename)
|
MEDIA_CACHE_MAX_AGE = 365 * 24 * 3600
|
||||||
|
|
||||||
|
|
||||||
|
def media_version(path):
|
||||||
|
"""Liefert die mtime einer Datei als Versionskennung fuer ?v=, 0 wenn sie fehlt."""
|
||||||
|
try:
|
||||||
|
return int(os.path.getmtime(path))
|
||||||
|
except OSError:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def send_media(directory, filename):
|
||||||
|
"""send_from_directory mit langem Cache, sofern die URL versioniert ist."""
|
||||||
|
if not request.args.get("v"):
|
||||||
|
return send_from_directory(directory, filename)
|
||||||
|
resp = send_from_directory(directory, filename, max_age=MEDIA_CACHE_MAX_AGE)
|
||||||
|
resp.cache_control.immutable = True
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/media/<site>/priority/<path:filename>")
|
||||||
|
def media_priority(site, filename):
|
||||||
|
"""Liefert die Priority-Medien eines Standorts aus media/<site>/priority/ aus."""
|
||||||
|
return send_media(priority_dir(site), filename)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/media/<site>/<screen>/<path:filename>")
|
@app.route("/media/<site>/<screen>/<path:filename>")
|
||||||
def media(site, screen, filename):
|
def media(site, screen, filename):
|
||||||
"""Liefert Medien-Dateien für einen bestimmten Screen aus."""
|
"""Liefert Medien-Dateien für einen bestimmten Screen aus."""
|
||||||
path = os.path.join(MEDIA_DIR, site, screen)
|
path = os.path.join(MEDIA_DIR, site, screen)
|
||||||
return send_from_directory(path, filename)
|
return send_media(path, filename)
|
||||||
|
|
||||||
|
|
||||||
# -------------------------------------------------
|
# -------------------------------------------------
|
||||||
@@ -1203,11 +1298,13 @@ def player(site, screen):
|
|||||||
if is_url(item):
|
if is_url(item):
|
||||||
url_data = normalize_url(item)
|
url_data = normalize_url(item)
|
||||||
if url_data:
|
if url_data:
|
||||||
normal_files.append({"kind": "url", "url": url_data["url"], "zoom": url_data.get("zoom", 1.0), "enabled": is_item_active(item), "passthrough": item.get("passthrough_params", False) if isinstance(item, dict) else False, "schedule": item.get("schedule") if isinstance(item, dict) else None})
|
normal_files.append({"kind": "url", "url": url_data["url"], "zoom": url_data.get("zoom", 1.0), "enabled": item.get("enabled", True) if isinstance(item, dict) else True,
|
||||||
|
"active": is_item_active(item), "passthrough": item.get("passthrough_params", False) if isinstance(item, dict) else False, "schedule": item.get("schedule") if isinstance(item, dict) else None})
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if item_name and os.path.exists(os.path.join(folder, item_name)) and allowed_file(item_name):
|
if item_name and os.path.exists(os.path.join(folder, item_name)) and allowed_file(item_name):
|
||||||
normal_files.append({"kind": "file", "name": item_name, "enabled": True})
|
normal_files.append({"kind": "file", "name": item_name, "enabled": True,
|
||||||
|
"v": media_version(os.path.join(folder, item_name))})
|
||||||
|
|
||||||
existing_file_names = {
|
existing_file_names = {
|
||||||
item["name"] for item in normal_files if item["kind"] == "file"
|
item["name"] for item in normal_files if item["kind"] == "file"
|
||||||
@@ -1219,10 +1316,11 @@ def player(site, screen):
|
|||||||
if f in existing_file_names or f.startswith("._"):
|
if f in existing_file_names or f.startswith("._"):
|
||||||
continue
|
continue
|
||||||
if allowed_file(f):
|
if allowed_file(f):
|
||||||
normal_files.append({"kind": "file", "name": f})
|
normal_files.append({"kind": "file", "name": f,
|
||||||
|
"v": media_version(os.path.join(folder, f))})
|
||||||
|
|
||||||
# ─── Priority-Playlist (global) ───
|
# ─── Priority-Playlist (global) ───
|
||||||
priority_cfg = config.get("priority", {})
|
priority_cfg = get_priority_config(config, site)
|
||||||
|
|
||||||
if priority_cfg.get("enabled", False):
|
if priority_cfg.get("enabled", False):
|
||||||
prio_files = []
|
prio_files = []
|
||||||
@@ -1233,12 +1331,14 @@ def player(site, screen):
|
|||||||
if is_url(item):
|
if is_url(item):
|
||||||
url_data = normalize_url(item)
|
url_data = normalize_url(item)
|
||||||
if url_data:
|
if url_data:
|
||||||
prio_files.append({"kind": "url", "url": url_data["url"], "zoom": url_data.get("zoom", 1.0), "enabled": is_item_active(item), "passthrough": item.get("passthrough_params", False) if isinstance(item, dict) else False, "schedule": item.get("schedule") if isinstance(item, dict) else None})
|
prio_files.append({"kind": "url", "url": url_data["url"], "zoom": url_data.get("zoom", 1.0), "enabled": item.get("enabled", True) if isinstance(item, dict) else True,
|
||||||
|
"active": is_item_active(item), "passthrough": item.get("passthrough_params", False) if isinstance(item, dict) else False, "schedule": item.get("schedule") if isinstance(item, dict) else None})
|
||||||
continue
|
continue
|
||||||
|
|
||||||
file_path = os.path.join(MEDIA_DIR, "priority", item_name)
|
file_path = os.path.join(priority_dir(site), item_name)
|
||||||
if os.path.exists(file_path):
|
if os.path.exists(file_path):
|
||||||
prio_files.append({"kind": "file", "name": item_name, "enabled": True})
|
prio_files.append({"kind": "file", "name": item_name, "enabled": True,
|
||||||
|
"v": media_version(file_path)})
|
||||||
else:
|
else:
|
||||||
prio_files = []
|
prio_files = []
|
||||||
|
|
||||||
@@ -1246,8 +1346,10 @@ def player(site, screen):
|
|||||||
"player.html",
|
"player.html",
|
||||||
site=site,
|
site=site,
|
||||||
screen=screen,
|
screen=screen,
|
||||||
normal_files=[item for item in normal_files if item.get("enabled", True)],
|
normal_files=[item for item in normal_files
|
||||||
prio_files=[item for item in prio_files if item.get("enabled", True)],
|
if item.get("enabled", True) and item.get("active", True)],
|
||||||
|
prio_files=[item for item in prio_files
|
||||||
|
if item.get("enabled", True) and item.get("active", True)],
|
||||||
interval=screen_cfg.get("interval", 10),
|
interval=screen_cfg.get("interval", 10),
|
||||||
newsticker_text=screen_cfg.get("newsticker_text", ""),
|
newsticker_text=screen_cfg.get("newsticker_text", ""),
|
||||||
newsticker_enabled=screen_cfg.get("newsticker_enabled", False),
|
newsticker_enabled=screen_cfg.get("newsticker_enabled", False),
|
||||||
@@ -1289,7 +1391,7 @@ def playlist_hash(site, screen):
|
|||||||
screen_playlist = (
|
screen_playlist = (
|
||||||
config.get("sites", {}).get(site, {}).get("screens", {}).get(screen, {}).get("playlist", [])
|
config.get("sites", {}).get(site, {}).get("screens", {}).get(screen, {}).get("playlist", [])
|
||||||
)
|
)
|
||||||
prio_cfg = config.get("priority", {})
|
prio_cfg = get_priority_config(config, site)
|
||||||
prio_playlist = prio_cfg.get("playlist", []) if prio_cfg.get("enabled", False) else []
|
prio_playlist = prio_cfg.get("playlist", []) if prio_cfg.get("enabled", False) else []
|
||||||
|
|
||||||
relevant = {
|
relevant = {
|
||||||
@@ -1375,6 +1477,7 @@ def admin(site):
|
|||||||
Admin-Dashboard für einen Standort.
|
Admin-Dashboard für einen Standort.
|
||||||
Zeigt alle Screens als Cards mit Tabler-Tabs (Playlist, Einstellungen, Medien).
|
Zeigt alle Screens als Cards mit Tabler-Tabs (Playlist, Einstellungen, Medien).
|
||||||
"""
|
"""
|
||||||
|
remember_site(site)
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
|
|
||||||
site_list = get_accessible_sites(cfg, current_user)
|
site_list = get_accessible_sites(cfg, current_user)
|
||||||
@@ -1401,11 +1504,18 @@ def admin(site):
|
|||||||
site_cfg = cfg.setdefault("sites", {}).setdefault(current_site, {"screens": {}})
|
site_cfg = cfg.setdefault("sites", {}).setdefault(current_site, {"screens": {}})
|
||||||
config_screens = site_cfg.setdefault("screens", {})
|
config_screens = site_cfg.setdefault("screens", {})
|
||||||
|
|
||||||
# Ordner im Medienverzeichnis = Screens
|
# Ordner im Medienverzeichnis = Screens.
|
||||||
|
# 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):
|
if os.path.isdir(site_media_base):
|
||||||
for entry in os.listdir(site_media_base):
|
for entry in os.listdir(site_media_base):
|
||||||
entry_path = os.path.join(site_media_base, entry)
|
entry_path = os.path.join(site_media_base, entry)
|
||||||
if os.path.isdir(entry_path) and not entry.startswith("._"):
|
if (os.path.isdir(entry_path)
|
||||||
|
and not entry.startswith("._")
|
||||||
|
and entry not in reserviert):
|
||||||
known_screens.add(entry)
|
known_screens.add(entry)
|
||||||
|
|
||||||
# Screens aus Config ergänzen (auch ohne Ordner)
|
# Screens aus Config ergänzen (auch ohne Ordner)
|
||||||
@@ -1435,7 +1545,8 @@ def admin(site):
|
|||||||
"type": "url",
|
"type": "url",
|
||||||
"size": "URL",
|
"size": "URL",
|
||||||
"zoom": url_data.get("zoom", 1.0),
|
"zoom": url_data.get("zoom", 1.0),
|
||||||
"enabled": is_item_active(item),
|
"enabled": item.get("enabled", True) if isinstance(item, dict) else True,
|
||||||
|
"active": is_item_active(item),
|
||||||
"passthrough": item.get("passthrough_params", False) if isinstance(item, dict) else False,
|
"passthrough": item.get("passthrough_params", False) if isinstance(item, dict) else False,
|
||||||
"schedule": schedule
|
"schedule": schedule
|
||||||
})
|
})
|
||||||
@@ -1459,7 +1570,8 @@ def admin(site):
|
|||||||
"name": item_name,
|
"name": item_name,
|
||||||
"type": ftype,
|
"type": ftype,
|
||||||
"size": size,
|
"size": size,
|
||||||
"enabled": is_item_active(item),
|
"enabled": item.get("enabled", True) if isinstance(item, dict) else True,
|
||||||
|
"active": is_item_active(item),
|
||||||
"schedule": schedule
|
"schedule": schedule
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1486,12 +1598,21 @@ def admin(site):
|
|||||||
screen_status[screen_name] = "online" if screen_is_active(current_site, screen_name) else "offline"
|
screen_status[screen_name] = "online" if screen_is_active(current_site, screen_name) else "offline"
|
||||||
client_info[screen_name] = get_client_info(current_site, screen_name)
|
client_info[screen_name] = get_client_info(current_site, screen_name)
|
||||||
|
|
||||||
|
# Priority-Einträge des Standorts nur zur Anzeige mitgeben: sie gelten für
|
||||||
|
# alle Screens, sind aber in einem eigenen Menüpunkt und werden hier leicht
|
||||||
|
# übersehen. Bedienung bleibt ausschließlich auf der Priority-Seite.
|
||||||
|
prio_cfg = get_priority_config(cfg, current_site) if current_site else {}
|
||||||
|
priority_preview = []
|
||||||
|
if current_site and prio_cfg.get("enabled", False):
|
||||||
|
priority_preview = [f for f in load_priority_files(current_site) if "enabled" in f]
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"admin.html",
|
"admin.html",
|
||||||
site_list=site_list,
|
site_list=site_list,
|
||||||
current_site=current_site,
|
current_site=current_site,
|
||||||
screens=screens,
|
screens=screens,
|
||||||
media_files=media_files,
|
media_files=media_files,
|
||||||
|
priority_preview=priority_preview,
|
||||||
screen_status=screen_status,
|
screen_status=screen_status,
|
||||||
client_info=client_info,
|
client_info=client_info,
|
||||||
version=APP_VERSION,
|
version=APP_VERSION,
|
||||||
@@ -1509,9 +1630,10 @@ def admin(site):
|
|||||||
@site_access_required
|
@site_access_required
|
||||||
def admin_priority(site):
|
def admin_priority(site):
|
||||||
"""Zeigt die Priority-Playlist-Seite mit Tabs (Playlist + Medien)."""
|
"""Zeigt die Priority-Playlist-Seite mit Tabs (Playlist + Medien)."""
|
||||||
|
remember_site(site)
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
site_list = get_accessible_sites(cfg, current_user)
|
site_list = get_accessible_sites(cfg, current_user)
|
||||||
priority_files = load_priority_files()
|
priority_files = load_priority_files(site)
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"priority.html",
|
"priority.html",
|
||||||
@@ -1707,7 +1829,8 @@ def upload(site, screen):
|
|||||||
filename = secure_filename(file.filename)
|
filename = secure_filename(file.filename)
|
||||||
|
|
||||||
if screen == "priority":
|
if screen == "priority":
|
||||||
file.save(os.path.join(MEDIA_DIR, "priority", filename))
|
os.makedirs(priority_dir(site), exist_ok=True)
|
||||||
|
file.save(os.path.join(priority_dir(site), filename))
|
||||||
else:
|
else:
|
||||||
file.save(os.path.join(MEDIA_DIR, site, screen, filename))
|
file.save(os.path.join(MEDIA_DIR, site, screen, filename))
|
||||||
|
|
||||||
@@ -1778,7 +1901,7 @@ def add_url(site, screen):
|
|||||||
|
|
||||||
config = load_config()
|
config = load_config()
|
||||||
if screen == "priority":
|
if screen == "priority":
|
||||||
config.setdefault("priority", {}).setdefault("playlist", []).append(url_data)
|
get_priority_config(config, site).setdefault("playlist", []).append(url_data)
|
||||||
else:
|
else:
|
||||||
cfg = get_screen_config(config, site, screen)
|
cfg = get_screen_config(config, site, screen)
|
||||||
cfg.setdefault("playlist", []).append(url_data)
|
cfg.setdefault("playlist", []).append(url_data)
|
||||||
@@ -1797,20 +1920,17 @@ def delete_file(site, screen, filename):
|
|||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
|
|
||||||
if screen == "priority":
|
if screen == "priority":
|
||||||
path = os.path.join(MEDIA_DIR, "priority", filename)
|
path = os.path.join(priority_dir(site), filename)
|
||||||
playlist = cfg.get("priority", {}).get("playlist", [])
|
playlist = get_priority_config(cfg, site).get("playlist", [])
|
||||||
playlist[:] = [item for item in playlist if not (
|
|
||||||
(isinstance(item, str) and item == filename) or
|
|
||||||
(isinstance(item, dict) and item.get("url") == filename)
|
|
||||||
)]
|
|
||||||
else:
|
else:
|
||||||
path = os.path.join(MEDIA_DIR, site, screen, filename)
|
path = os.path.join(MEDIA_DIR, site, screen, filename)
|
||||||
screens_cfg = cfg.get("sites", {}).get(site, {}).get("screens", {})
|
screens_cfg = cfg.get("sites", {}).get(site, {}).get("screens", {})
|
||||||
playlist = screens_cfg.get(screen, {}).get("playlist", [])
|
playlist = screens_cfg.get(screen, {}).get("playlist", [])
|
||||||
playlist[:] = [item for item in playlist if not (
|
|
||||||
(isinstance(item, str) and item == filename) or
|
# Ueber playlist_item_name vergleichen: Datei-Eintraege stehen als
|
||||||
(isinstance(item, dict) and item.get("url") == filename)
|
# {"name": ...}, URLs als {"url": ...}, Altbestand als reiner String.
|
||||||
)]
|
playlist[:] = [item for item in playlist
|
||||||
|
if playlist_item_name(item) != filename]
|
||||||
|
|
||||||
if os.path.exists(path):
|
if os.path.exists(path):
|
||||||
os.remove(path)
|
os.remove(path)
|
||||||
@@ -1835,7 +1955,7 @@ def save_playlist(site, screen):
|
|||||||
enabled_map = data.get("enabled", {})
|
enabled_map = data.get("enabled", {})
|
||||||
|
|
||||||
if screen == "priority":
|
if screen == "priority":
|
||||||
old_playlist = cfg.get("priority", {}).get("playlist", [])
|
old_playlist = get_priority_config(cfg, site).get("playlist", [])
|
||||||
else:
|
else:
|
||||||
screens_cfg = cfg.get("sites", {}).get(site, {}).get("screens", {})
|
screens_cfg = cfg.get("sites", {}).get(site, {}).get("screens", {})
|
||||||
old_playlist = screens_cfg.get(screen, {}).get("playlist", [])
|
old_playlist = screens_cfg.get(screen, {}).get("playlist", [])
|
||||||
@@ -1856,7 +1976,7 @@ def save_playlist(site, screen):
|
|||||||
new_playlist.append({"url": key, "zoom": 1.0, "enabled": enabled_map.get(key, True)} if is_url(key) else {"name": key, "enabled": enabled_map.get(key, True)})
|
new_playlist.append({"url": key, "zoom": 1.0, "enabled": enabled_map.get(key, True)} if is_url(key) else {"name": key, "enabled": enabled_map.get(key, True)})
|
||||||
|
|
||||||
if screen == "priority":
|
if screen == "priority":
|
||||||
cfg.setdefault("priority", {})["playlist"] = new_playlist
|
get_priority_config(cfg, site)["playlist"] = new_playlist
|
||||||
else:
|
else:
|
||||||
cfg.setdefault("sites", {}).setdefault(site, {}).setdefault("screens", {}).setdefault(screen, {})["playlist"] = new_playlist
|
cfg.setdefault("sites", {}).setdefault(site, {}).setdefault("screens", {}).setdefault(screen, {})["playlist"] = new_playlist
|
||||||
|
|
||||||
@@ -1895,7 +2015,7 @@ def update_playlist_item(site, screen):
|
|||||||
config = load_config()
|
config = load_config()
|
||||||
|
|
||||||
if screen == "priority":
|
if screen == "priority":
|
||||||
playlist = config.setdefault("priority", {}).setdefault("playlist", [])
|
playlist = get_priority_config(config, site).setdefault("playlist", [])
|
||||||
else:
|
else:
|
||||||
playlist = config["sites"][site]["screens"][screen].setdefault("playlist", [])
|
playlist = config["sites"][site]["screens"][screen].setdefault("playlist", [])
|
||||||
|
|
||||||
@@ -1998,8 +2118,19 @@ def admin_dashboard():
|
|||||||
admin_count = sum(1 for u in users_data.values() if u.get("role") == "admin")
|
admin_count = sum(1 for u in users_data.values() if u.get("role") == "admin")
|
||||||
superuser_count = sum(1 for u in users_data.values() if u.get("role") == "superuser")
|
superuser_count = sum(1 for u in users_data.values() if u.get("role") == "superuser")
|
||||||
|
|
||||||
|
# Kennzahlen und Sparklines bleiben bewusst am jüngsten Ausschnitt haengen,
|
||||||
|
# damit sie sich beim Blaettern im Verlauf nicht veraendern.
|
||||||
history = get_history(30)
|
history = get_history(30)
|
||||||
|
|
||||||
|
gesamt = get_history_count()
|
||||||
|
seiten = max(1, (gesamt + HISTORY_PER_PAGE - 1) // HISTORY_PER_PAGE)
|
||||||
|
try:
|
||||||
|
seite = int(request.args.get("page", 1))
|
||||||
|
except ValueError:
|
||||||
|
seite = 1
|
||||||
|
seite = min(max(seite, 1), seiten)
|
||||||
|
history_page = get_history(HISTORY_PER_PAGE, (seite - 1) * HISTORY_PER_PAGE)
|
||||||
|
|
||||||
# Sparkline-Daten aus dem Verlauf berechnen
|
# Sparkline-Daten aus dem Verlauf berechnen
|
||||||
def build_sparkline(entries, creates, deletes):
|
def build_sparkline(entries, creates, deletes):
|
||||||
seq = list(reversed(entries))
|
seq = list(reversed(entries))
|
||||||
@@ -2048,9 +2179,14 @@ def admin_dashboard():
|
|||||||
user_trend=user_trend,
|
user_trend=user_trend,
|
||||||
admin_trend=admin_trend,
|
admin_trend=admin_trend,
|
||||||
superuser_trend=superuser_trend,
|
superuser_trend=superuser_trend,
|
||||||
history=history,
|
history=history_page,
|
||||||
|
history_page=seite,
|
||||||
|
history_pages=seiten,
|
||||||
|
history_total=gesamt,
|
||||||
|
history_from=(seite - 1) * HISTORY_PER_PAGE + 1 if gesamt else 0,
|
||||||
|
history_to=min(seite * HISTORY_PER_PAGE, gesamt),
|
||||||
site_list=get_accessible_sites(cfg, current_user),
|
site_list=get_accessible_sites(cfg, current_user),
|
||||||
current_site=get_site_list(cfg)[0] if get_site_list(cfg) else "",
|
current_site=current_site_for(cfg, current_user),
|
||||||
version=APP_VERSION,
|
version=APP_VERSION,
|
||||||
year=datetime.now().year,
|
year=datetime.now().year,
|
||||||
hostname=os.uname().nodename,
|
hostname=os.uname().nodename,
|
||||||
@@ -2066,7 +2202,7 @@ def admin_help():
|
|||||||
return render_template(
|
return render_template(
|
||||||
"help.html",
|
"help.html",
|
||||||
site_list=get_accessible_sites(cfg, current_user),
|
site_list=get_accessible_sites(cfg, current_user),
|
||||||
current_site=get_site_list(cfg)[0] if get_site_list(cfg) else "",
|
current_site=current_site_for(cfg, current_user),
|
||||||
version=APP_VERSION,
|
version=APP_VERSION,
|
||||||
year=datetime.now().year,
|
year=datetime.now().year,
|
||||||
hostname=os.uname().nodename,
|
hostname=os.uname().nodename,
|
||||||
@@ -2099,7 +2235,7 @@ def admin_users():
|
|||||||
"user_list.html",
|
"user_list.html",
|
||||||
users=sorted(users_list, key=lambda u: (u["last_name"].lower(), u["first_name"].lower())),
|
users=sorted(users_list, key=lambda u: (u["last_name"].lower(), u["first_name"].lower())),
|
||||||
site_list=get_accessible_sites(cfg, current_user),
|
site_list=get_accessible_sites(cfg, current_user),
|
||||||
current_site=get_site_list(cfg)[0] if get_site_list(cfg) else "",
|
current_site=current_site_for(cfg, current_user),
|
||||||
version=APP_VERSION,
|
version=APP_VERSION,
|
||||||
year=datetime.now().year,
|
year=datetime.now().year,
|
||||||
hostname=os.uname().nodename,
|
hostname=os.uname().nodename,
|
||||||
|
|||||||
138
config.json
138
config.json
@@ -1,25 +1,6 @@
|
|||||||
{
|
{
|
||||||
"server_url": "http://localhost:5005",
|
"server_url": "http://localhost:5005",
|
||||||
"voice_agent_url": "https://dva-stage.buds.cancom.ai/",
|
"voice_agent_url": "https://dva-stage.buds.cancom.ai/",
|
||||||
"priority": {
|
|
||||||
"enabled": true,
|
|
||||||
"playlist": [
|
|
||||||
{
|
|
||||||
"url": "https://wbxroompresence.cancom.io/standort?find=Stuttgart",
|
|
||||||
"zoom": 0.8,
|
|
||||||
"enabled": false,
|
|
||||||
"passthrough_params": false,
|
|
||||||
"schedule": {
|
|
||||||
"mode": "weekly",
|
|
||||||
"start": "08:00",
|
|
||||||
"end": "09:00",
|
|
||||||
"days": [
|
|
||||||
"0"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"sites": {
|
"sites": {
|
||||||
"stuttgart": {
|
"stuttgart": {
|
||||||
"screens": {
|
"screens": {
|
||||||
@@ -76,11 +57,13 @@
|
|||||||
"enabled": true
|
"enabled": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"newsticker_text": "Herzlich willkommen bei der CANCOM - wir w\u00fcnschen ihnen einen sch\u00f6nen Tag",
|
"newsticker_text": "",
|
||||||
"newsticker_enabled": true,
|
"newsticker_enabled": false,
|
||||||
"custom_url": "https://www.meteoblue.com/en/meteotv/d7b0fd",
|
"custom_url": "https://www.meteoblue.com/en/meteotv/d7b0fd",
|
||||||
"custom_url_label": "Wetter",
|
"custom_url_label": "Wetter",
|
||||||
"custom_url_enabled": false
|
"custom_url_enabled": false,
|
||||||
|
"stay_on_first": false,
|
||||||
|
"welcome_enabled": false
|
||||||
},
|
},
|
||||||
"videosysteme": {
|
"videosysteme": {
|
||||||
"interval": 20,
|
"interval": 20,
|
||||||
@@ -102,6 +85,25 @@
|
|||||||
"https://cdn.brandfetch.io/www.ibm.com/logo?c=1idyd4Tpb2nKaXIIc8T",
|
"https://cdn.brandfetch.io/www.ibm.com/logo?c=1idyd4Tpb2nKaXIIc8T",
|
||||||
"https://cdn.brandfetch.io/www.soliver.com/logo?c=1idyd4Tpb2nKaXIIc8T"
|
"https://cdn.brandfetch.io/www.soliver.com/logo?c=1idyd4Tpb2nKaXIIc8T"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"priority": {
|
||||||
|
"enabled": true,
|
||||||
|
"playlist": [
|
||||||
|
{
|
||||||
|
"url": "https://wbxroompresence.cancom.io/standort?find=Stuttgart",
|
||||||
|
"zoom": 0.8,
|
||||||
|
"enabled": false,
|
||||||
|
"passthrough_params": false,
|
||||||
|
"schedule": {
|
||||||
|
"mode": "weekly",
|
||||||
|
"start": "08:00",
|
||||||
|
"end": "09:00",
|
||||||
|
"days": [
|
||||||
|
"0"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"karlsruhe": {
|
"karlsruhe": {
|
||||||
@@ -152,6 +154,98 @@
|
|||||||
"logo_urls": [
|
"logo_urls": [
|
||||||
"https://cdn.brandfetch.io/weisenburger.de/logo?c=1idyd4Tpb2nKaXIIc8T"
|
"https://cdn.brandfetch.io/weisenburger.de/logo?c=1idyd4Tpb2nKaXIIc8T"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"priority": {
|
||||||
|
"enabled": true,
|
||||||
|
"playlist": [
|
||||||
|
{
|
||||||
|
"url": "https://wbxroompresence.cancom.io/standort?find=Stuttgart",
|
||||||
|
"zoom": 0.8,
|
||||||
|
"enabled": false,
|
||||||
|
"passthrough_params": false,
|
||||||
|
"schedule": {
|
||||||
|
"mode": "weekly",
|
||||||
|
"start": "08:00",
|
||||||
|
"end": "09:00",
|
||||||
|
"days": [
|
||||||
|
"0"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"screens": {
|
||||||
|
"vorstand": {
|
||||||
|
"playlist": [
|
||||||
|
{
|
||||||
|
"name": "welcome.html",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Artikelbild_Weindorf.jpg",
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"interval": 10,
|
||||||
|
"show_images": true,
|
||||||
|
"show_videos": true,
|
||||||
|
"newsticker_text": "",
|
||||||
|
"newsticker_enabled": false,
|
||||||
|
"stay_on_first": false,
|
||||||
|
"welcome_enabled": true
|
||||||
|
},
|
||||||
|
"casino": {
|
||||||
|
"playlist": [],
|
||||||
|
"interval": 10,
|
||||||
|
"show_images": true,
|
||||||
|
"show_videos": true,
|
||||||
|
"newsticker_text": "",
|
||||||
|
"newsticker_enabled": false,
|
||||||
|
"stay_on_first": false,
|
||||||
|
"welcome_enabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"welcome_data": {
|
||||||
|
"names": [
|
||||||
|
"WIN",
|
||||||
|
"IBM",
|
||||||
|
"Gaufer"
|
||||||
|
],
|
||||||
|
"logo_urls": [
|
||||||
|
"/media/test/logos/logo_1.png?v=1788363702",
|
||||||
|
"https://cdn.brandfetch.io/www.ibm.com/logo?c=1idyd4Tpb2nKaXIIc8T",
|
||||||
|
"/media/test/logos/logo_3.png?v=1788364382"
|
||||||
|
],
|
||||||
|
"contacts": [
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
""
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"priority": {
|
||||||
|
"enabled": true,
|
||||||
|
"playlist": [
|
||||||
|
{
|
||||||
|
"url": "https://wbxroompresence.cancom.io/standort?find=Stuttgart",
|
||||||
|
"zoom": 0.8,
|
||||||
|
"enabled": false,
|
||||||
|
"passthrough_params": false,
|
||||||
|
"schedule": {
|
||||||
|
"mode": "weekly",
|
||||||
|
"start": "08:00",
|
||||||
|
"end": "09:00",
|
||||||
|
"days": [
|
||||||
|
"0"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "IMG_6249.jpeg",
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
BIN
docs/Simple-Signage-Ueberblick.pdf
Normal file
BIN
docs/Simple-Signage-Ueberblick.pdf
Normal file
Binary file not shown.
4
docs/cancom.svg
Executable file
4
docs/cancom.svg
Executable 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
383
docs/onepager.html
Normal 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. 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 ·
|
||||||
|
<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 · 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>
|
||||||
@@ -65,6 +65,22 @@ def _border_color(px, w, h):
|
|||||||
return mitte, streuung <= LOGO_BORDER_UNIFORMITY
|
return mitte, streuung <= LOGO_BORDER_UNIFORMITY
|
||||||
|
|
||||||
|
|
||||||
|
def trim_transparent_border(img):
|
||||||
|
"""Schneidet durchsichtige Raender weg, sodass das Logo die Bildflaeche
|
||||||
|
ausfuellt.
|
||||||
|
|
||||||
|
Ohne das erscheint ein hochgeladenes Logo kleiner als ein automatisch
|
||||||
|
gefundenes: die Willkommensseite skaliert mit object-fit: contain auf
|
||||||
|
eine feste Hoehe, und ein breiter durchsichtiger Rand zaehlt dabei mit.
|
||||||
|
Brandfetch-Logos sind bereits eng beschnitten."""
|
||||||
|
if img.mode != "RGBA":
|
||||||
|
return img
|
||||||
|
bbox = img.getchannel("A").getbbox()
|
||||||
|
if not bbox or bbox == (0, 0, img.width, img.height):
|
||||||
|
return img
|
||||||
|
return img.crop(bbox)
|
||||||
|
|
||||||
|
|
||||||
def remove_logo_background(image, tolerance=LOGO_TOLERANCE):
|
def remove_logo_background(image, tolerance=LOGO_TOLERANCE):
|
||||||
"""Stellt ein Logo frei: entfernt den zusammenhängenden Hintergrund von den
|
"""Stellt ein Logo frei: entfernt den zusammenhängenden Hintergrund von den
|
||||||
Rändern her und macht ihn transparent.
|
Rändern her und macht ihn transparent.
|
||||||
@@ -96,6 +112,10 @@ def remove_logo_background(image, tolerance=LOGO_TOLERANCE):
|
|||||||
return img, ("Der Bildrand ist nicht einfarbig – das Logo wurde unverändert "
|
return img, ("Der Bildrand ist nicht einfarbig – das Logo wurde unverändert "
|
||||||
"übernommen. Am besten ein PNG mit transparentem Hintergrund hochladen.")
|
"ü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
|
# Flood Fill von allen Randpixeln aus
|
||||||
besucht = bytearray(w * h)
|
besucht = bytearray(w * h)
|
||||||
queue = deque()
|
queue = deque()
|
||||||
@@ -139,13 +159,31 @@ def remove_logo_background(image, tolerance=LOGO_TOLERANCE):
|
|||||||
if d < tolerance * 2:
|
if d < tolerance * 2:
|
||||||
px[x, y] = (r, g, b, int(255 * max(0.0, (d - tolerance) / tolerance)))
|
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, "Es wurde kein zusammenhängender Hintergrund gefunden."
|
||||||
|
|
||||||
return img, None
|
return img, None
|
||||||
|
|
||||||
|
|
||||||
LOGO_DIRNAME = "logos"
|
# Logos liegen als Dateien direkt im Standort-Verzeichnis, nicht in einem
|
||||||
|
# Unterordner: admin() behandelt jeden Unterordner von media/<site>/ als Screen
|
||||||
|
# und legt ihn dauerhaft in der Konfiguration an. Das Hintergrundbild wird aus
|
||||||
|
# demselben Grund als media/<site>/background.* abgelegt.
|
||||||
|
LOGO_URL_PREFIX = "logos" # nur der Pfadbestandteil in der URL
|
||||||
|
LOGO_FILENAME = "logo_{slot}.png"
|
||||||
LOGO_EXTENSIONS = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp")
|
LOGO_EXTENSIONS = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp")
|
||||||
|
|
||||||
|
|
||||||
@@ -175,12 +213,19 @@ def save_uploaded_logo(file_storage, site, slot, freistellen=True):
|
|||||||
else:
|
else:
|
||||||
img = img.convert("RGBA")
|
img = img.convert("RGBA")
|
||||||
|
|
||||||
logo_dir = os.path.join(MEDIA_DIR, site, LOGO_DIRNAME)
|
# Nach dem Freistellen zuschneiden, damit das Logo genauso gross erscheint
|
||||||
os.makedirs(logo_dir, exist_ok=True)
|
# wie ein automatisch gefundenes
|
||||||
dateiname = f"logo_{slot}.png"
|
vorher = img.size
|
||||||
img.save(os.path.join(logo_dir, dateiname), "PNG")
|
img = trim_transparent_border(img)
|
||||||
|
if img.size != vorher:
|
||||||
|
print(f"✅ Generate - Logo zugeschnitten: {vorher[0]}x{vorher[1]} → {img.size[0]}x{img.size[1]}")
|
||||||
|
|
||||||
url = f"/media/{site}/{LOGO_DIRNAME}/{dateiname}?v={int(time.time())}"
|
site_dir = os.path.join(MEDIA_DIR, site)
|
||||||
|
os.makedirs(site_dir, exist_ok=True)
|
||||||
|
dateiname = LOGO_FILENAME.format(slot=slot)
|
||||||
|
img.save(os.path.join(site_dir, dateiname), "PNG")
|
||||||
|
|
||||||
|
url = f"/media/{site}/{LOGO_URL_PREFIX}/{dateiname}?v={int(time.time())}"
|
||||||
print(f"✅ Generate - Logo hochgeladen: {url}"
|
print(f"✅ Generate - Logo hochgeladen: {url}"
|
||||||
+ (f" (Hinweis: {hinweis})" if hinweis else ""))
|
+ (f" (Hinweis: {hinweis})" if hinweis else ""))
|
||||||
return url, hinweis
|
return url, hinweis
|
||||||
|
|||||||
300
history.json
300
history.json
@@ -1,4 +1,304 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"timestamp": "2026-09-07 10:45:56",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 't@x.de' angemeldet (Trusted Device)",
|
||||||
|
"user": "t@x.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-09-07 10:45:56",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 't@x.de' angemeldet (mit MFA, Ger\u00e4t f\u00fcr 7 Tage gespeichert)",
|
||||||
|
"user": "t@x.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-09-07 10:44:24",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 't@x.de' angemeldet (Trusted Device)",
|
||||||
|
"user": "t@x.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-09-07 10:44:24",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 't@x.de' angemeldet (mit MFA, Ger\u00e4t f\u00fcr 7 Tage gespeichert)",
|
||||||
|
"user": "t@x.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-09-04 10:38:34",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 'erik.thiele@cancom.de' angemeldet",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-09-02 17:58:05",
|
||||||
|
"action": "screen_deleted",
|
||||||
|
"detail": "Screen 'xxx' an Standort 'test' gel\u00f6scht",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-09-02 17:57:39",
|
||||||
|
"action": "screen_created",
|
||||||
|
"detail": "Screen 'xxx' an Standort 'test' angelegt",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-09-02 17:56:56",
|
||||||
|
"action": "background_uploaded",
|
||||||
|
"detail": "Hintergrundbild f\u00fcr Standort 'test' hochgeladen",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-09-02 17:53:02",
|
||||||
|
"action": "customer_added",
|
||||||
|
"detail": "Kunden (WIN, IBM, Gaufer) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: casino, vorstand)",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-09-02 17:44:39",
|
||||||
|
"action": "screen_created",
|
||||||
|
"detail": "Screen 'casino' an Standort 'test' angelegt",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-09-02 17:44:31",
|
||||||
|
"action": "site_deleted",
|
||||||
|
"detail": "Standort 'casino' gel\u00f6scht",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-09-02 17:44:25",
|
||||||
|
"action": "site_created",
|
||||||
|
"detail": "Standort 'casino' angelegt",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-09-02 17:41:44",
|
||||||
|
"action": "customer_added",
|
||||||
|
"detail": "Kunden (WIN, IBM) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-09-02 17:41:32",
|
||||||
|
"action": "customer_added",
|
||||||
|
"detail": "Kunden (IBM) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-09-02 17:40:55",
|
||||||
|
"action": "customer_added",
|
||||||
|
"detail": "Kunden (XXTT) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-09-02 16:10:33",
|
||||||
|
"action": "customer_added",
|
||||||
|
"detail": "Kunden (Teamshop Kuenzelsau) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-09-02 15:59:00",
|
||||||
|
"action": "customer_added",
|
||||||
|
"detail": "Kunden (Teamshop Kuenzelsau) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-09-02 15:52:39",
|
||||||
|
"action": "customer_added",
|
||||||
|
"detail": "Kunden (Teamshop Kuenzelsau, IBM) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-09-02 15:52:13",
|
||||||
|
"action": "customer_added",
|
||||||
|
"detail": "Kunden (Teamshop Kuenzelsau, IBM) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-09-02 15:52:04",
|
||||||
|
"action": "customer_added",
|
||||||
|
"detail": "Kunden (Teamshop Kuenzelsau, IBM) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-09-02 15:42:42",
|
||||||
|
"action": "customer_added",
|
||||||
|
"detail": "Kunden (Teamshop Kuenzelsau) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-09-02 15:42:25",
|
||||||
|
"action": "customer_added",
|
||||||
|
"detail": "Kunden (Teamshop Kuenzelsau) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-09-02 15:36:52",
|
||||||
|
"action": "customer_added",
|
||||||
|
"detail": "Kunden (Teamshop Kuenzelsau) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
|
||||||
|
"user": "admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-09-02 15:32:23",
|
||||||
|
"action": "customer_added",
|
||||||
|
"detail": "Kunden (IBM) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-09-02 15:32:06",
|
||||||
|
"action": "customer_added",
|
||||||
|
"detail": "Kunden (IBM) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-09-02 15:31:59",
|
||||||
|
"action": "customer_added",
|
||||||
|
"detail": "Kunden (IBM) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-09-02 15:31:44",
|
||||||
|
"action": "customer_added",
|
||||||
|
"detail": "Kunden (IBM) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-09-02 15:31:31",
|
||||||
|
"action": "customer_added",
|
||||||
|
"detail": "Kunden (IBM) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-09-02 15:31:14",
|
||||||
|
"action": "customer_added",
|
||||||
|
"detail": "Kunden (IBM) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-09-02 15:30:36",
|
||||||
|
"action": "customer_added",
|
||||||
|
"detail": "Kunden (Teamshop Kuenzelsau) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-09-02 15:28:22",
|
||||||
|
"action": "customer_added",
|
||||||
|
"detail": "Kunden (Teamshop Kuenzelsau) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
|
||||||
|
"user": "admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-09-02 15:26:50",
|
||||||
|
"action": "customer_added",
|
||||||
|
"detail": "Kunden (HellesLogo) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
|
||||||
|
"user": "admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-09-02 15:23:36",
|
||||||
|
"action": "customer_added",
|
||||||
|
"detail": "Kunden (UploadTest) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
|
||||||
|
"user": "admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-09-02 15:22:22",
|
||||||
|
"action": "customer_added",
|
||||||
|
"detail": "Kunden (ibm) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-09-02 15:21:07",
|
||||||
|
"action": "customer_added",
|
||||||
|
"detail": "Kunden (ibm) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-09-02 15:19:35",
|
||||||
|
"action": "customer_added",
|
||||||
|
"detail": "Kunden (ibm) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-09-02 15:19:21",
|
||||||
|
"action": "customer_added",
|
||||||
|
"detail": "Kunden (ibm) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-09-02 15:09:15",
|
||||||
|
"action": "customer_added",
|
||||||
|
"detail": "Kunden (ibm) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: logos, vorstand)",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-09-02 15:08:35",
|
||||||
|
"action": "customer_added",
|
||||||
|
"detail": "Kunden (ibm) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: logos, vorstand)",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-09-02 15:08:14",
|
||||||
|
"action": "customer_added",
|
||||||
|
"detail": "Kunden (ibm) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: logos, vorstand)",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-09-02 15:07:50",
|
||||||
|
"action": "customer_added",
|
||||||
|
"detail": "Kunden (ibm, lenovo, logitech) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: logos, vorstand)",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-09-02 15:02:34",
|
||||||
|
"action": "customer_added",
|
||||||
|
"detail": "Kunden (ibm, lenovo, logitech) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: logos, vorstand)",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-09-02 15:02:01",
|
||||||
|
"action": "customer_added",
|
||||||
|
"detail": "Kunden (ibm, lenovo, logitech) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: logos, vorstand)",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-09-02 15:01:07",
|
||||||
|
"action": "customer_added",
|
||||||
|
"detail": "Kunden (ibm, lenovo, logitech) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: logos, vorstand)",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-09-02 14:35:54",
|
||||||
|
"action": "customer_added",
|
||||||
|
"detail": "Kunden (Testkunde) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
|
||||||
|
"user": "admin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-09-02 14:31:04",
|
||||||
|
"action": "screen_created",
|
||||||
|
"detail": "Screen 'vorstand' an Standort 'test' angelegt",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-09-02 14:30:58",
|
||||||
|
"action": "site_created",
|
||||||
|
"detail": "Standort 'test' angelegt",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-09-02 14:30:51",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 'erik.thiele@cancom.de' angemeldet",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-09-02 14:29:42",
|
||||||
|
"action": "password_changed",
|
||||||
|
"detail": "Passwort f\u00fcr User 'erik.thiele@cancom.de' ge\u00e4ndert",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-09-02 14:29:34",
|
||||||
|
"action": "user_login",
|
||||||
|
"detail": "User 'erik.thiele@cancom.de' angemeldet (Passwort-\u00c4nderung erforderlich)",
|
||||||
|
"user": "erik.thiele@cancom.de"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"timestamp": "2026-08-29 16:20:09",
|
"timestamp": "2026-08-29 16:20:09",
|
||||||
"action": "user_login",
|
"action": "user_login",
|
||||||
|
|||||||
@@ -52,6 +52,19 @@
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Prio-Vorschau: reine Anzeige, keine Bedienung ─── */
|
||||||
|
.playlist-prio {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 28px 1fr 90px 80px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
opacity: 0.72;
|
||||||
|
background: var(--tblr-bg-surface-secondary, #f8f9fa);
|
||||||
|
}
|
||||||
|
.playlist-prio .playlist-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
.playlist-name {
|
.playlist-name {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -229,6 +242,32 @@
|
|||||||
|
|
||||||
<!-- ═══ Tab: Playlist ═══ -->
|
<!-- ═══ Tab: Playlist ═══ -->
|
||||||
<div class="tab-pane active" id="tab-playlist-{{ screen }}" role="tabpanel">
|
<div class="tab-pane active" id="tab-playlist-{{ screen }}" role="tabpanel">
|
||||||
|
{% if priority_preview %}
|
||||||
|
<!-- Priority-Playlist des Standorts: laeuft diesen Screens vorweg.
|
||||||
|
Reine Anzeige - Bedienung ausschliesslich unter "Priority". -->
|
||||||
|
<ul class="list-group mb-2">
|
||||||
|
{% for file in priority_preview %}
|
||||||
|
<li class="list-group-item playlist-prio">
|
||||||
|
<i class="ti ti-player-play" title="Laeuft vorweg"></i>
|
||||||
|
<span class="playlist-name">
|
||||||
|
{{ file.name }}
|
||||||
|
{% if file.schedule %}<span class="badge ms-1 {% if file.active %}bg-info text-white{% else %}bg-secondary-lt{% endif %}" title="{{ file.schedule | schedule_text }}{% if not file.active %} — laeuft gerade nicht{% endif %}">Zeit</span>{% endif %}
|
||||||
|
{% if not file.enabled %}<span class="badge bg-secondary-lt ms-1">inaktiv</span>{% endif %}
|
||||||
|
</span>
|
||||||
|
<span class="badge bg-red text-red-fg">Prio</span>
|
||||||
|
<span class="playlist-size">
|
||||||
|
{% if file.type == "url" %}{{ file.size }}{% else %}{{ file.size }} KB{% endif %}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<p class="text-muted small mb-3">
|
||||||
|
Diese Einträge kommen aus der
|
||||||
|
<a href="{{ url_for('admin_priority', site=current_site) }}">Priority-Playlist</a>
|
||||||
|
des Standorts und laufen allen Screens vorweg. Bearbeiten nur dort.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<ul class="list-group mb-3" id="playlist-{{ screen }}">
|
<ul class="list-group mb-3" id="playlist-{{ screen }}">
|
||||||
{% for file in media_files[screen] %}
|
{% for file in media_files[screen] %}
|
||||||
<li class="list-group-item playlist-row"
|
<li class="list-group-item playlist-row"
|
||||||
@@ -251,7 +290,7 @@
|
|||||||
title="URL: {{ file.name }}{% if file.zoom %} (Zoom: {{ file.zoom }}x){% endif %}"
|
title="URL: {{ file.name }}{% if file.zoom %} (Zoom: {{ file.zoom }}x){% endif %}"
|
||||||
{% endif %}>
|
{% endif %}>
|
||||||
{{ file.name }}{% if file.type == "url" and file.zoom != 1.0 %} <strong style="color: #DA002D;">[{{ file.zoom }}x]</strong>{% endif %}{% if file.type == "url" %}{% if file.get('passthrough', False) %} <i class="ti ti-link" style="color:#6c757d;" title="Query-Parameter werden übergeben"></i>{% else %} <i class="ti ti-link-off" style="color:#adb5bd;" title="Query-Parameter werden nicht übergeben"></i>{% endif %}{% endif %}
|
{{ file.name }}{% if file.type == "url" and file.zoom != 1.0 %} <strong style="color: #DA002D;">[{{ file.zoom }}x]</strong>{% endif %}{% if file.type == "url" %}{% if file.get('passthrough', False) %} <i class="ti ti-link" style="color:#6c757d;" title="Query-Parameter werden übergeben"></i>{% else %} <i class="ti ti-link-off" style="color:#adb5bd;" title="Query-Parameter werden nicht übergeben"></i>{% endif %}{% endif %}
|
||||||
{% if file.schedule %}<span class="badge bg-info text-white ms-1" title="{{ file.schedule | schedule_text }}">Zeit</span>{% endif %}
|
{% if file.schedule %}<span class="badge ms-1 {% if file.active %}bg-info text-white{% else %}bg-secondary-lt{% endif %}" title="{{ file.schedule | schedule_text }}{% if not file.active %} — laeuft gerade nicht{% endif %}">Zeit</span>{% endif %}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<!-- Typ-Badge -->
|
<!-- Typ-Badge -->
|
||||||
|
|||||||
@@ -121,6 +121,9 @@
|
|||||||
<div class="card mt-4">
|
<div class="card mt-4">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3 class="card-title"><i class="ti ti-history me-1"></i>Verlauf</h3>
|
<h3 class="card-title"><i class="ti ti-history me-1"></i>Verlauf</h3>
|
||||||
|
{% if history_total %}
|
||||||
|
<span class="card-subtitle text-secondary small ms-auto">{{ history_from }}–{{ history_to }} von {{ history_total }}</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
<div class="list-group list-group-flush">
|
<div class="list-group list-group-flush">
|
||||||
@@ -171,6 +174,29 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% if history_pages > 1 %}
|
||||||
|
<div class="card-footer d-flex align-items-center">
|
||||||
|
<p class="m-0 text-secondary small">Seite {{ history_page }} von {{ history_pages }}</p>
|
||||||
|
<ul class="pagination m-0 ms-auto">
|
||||||
|
<li class="page-item {% if history_page <= 1 %}disabled{% endif %}">
|
||||||
|
<a class="page-link" href="?page={{ history_page - 1 }}"><i class="ti ti-chevron-left"></i> zurück</a>
|
||||||
|
</li>
|
||||||
|
{# Nur ein Fenster um die aktuelle Seite zeigen, sonst wird die Leiste bei vielen Seiten zu lang #}
|
||||||
|
{% for p in range(1, history_pages + 1) %}
|
||||||
|
{% if p == 1 or p == history_pages or (p >= history_page - 2 and p <= history_page + 2) %}
|
||||||
|
<li class="page-item {% if p == history_page %}active{% endif %}">
|
||||||
|
<a class="page-link" href="?page={{ p }}">{{ p }}</a>
|
||||||
|
</li>
|
||||||
|
{% elif p == history_page - 3 or p == history_page + 3 %}
|
||||||
|
<li class="page-item disabled"><span class="page-link">…</span></li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
<li class="page-item {% if history_page >= history_pages %}disabled{% endif %}">
|
||||||
|
<a class="page-link" href="?page={{ history_page + 1 }}">weiter <i class="ti ti-chevron-right"></i></a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -153,9 +153,13 @@
|
|||||||
<textarea class="form-control" id="contact_{{ i + 1 }}" name="contact_{{ i + 1 }}" rows="4"
|
<textarea class="form-control" id="contact_{{ i + 1 }}" name="contact_{{ i + 1 }}" rows="4"
|
||||||
placeholder="{{ slots[i][1] }}">{{ contacts[i] if contacts else '' }}</textarea>
|
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>
|
<hr class="mt-4 mb-3">
|
||||||
<input type="file" class="form-control" id="logo_file_{{ i + 1 }}"
|
|
||||||
name="logo_file_{{ i + 1 }}" accept="image/*">
|
<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">
|
<div class="form-check mt-1">
|
||||||
<input class="form-check-input" type="checkbox"
|
<input class="form-check-input" type="checkbox"
|
||||||
id="logo_keep_bg_{{ i + 1 }}" name="logo_keep_bg_{{ i + 1 }}">
|
id="logo_keep_bg_{{ i + 1 }}" name="logo_keep_bg_{{ i + 1 }}">
|
||||||
@@ -165,6 +169,10 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</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">
|
<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.
|
<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
|
Der Hintergrund wird automatisch freigestellt, damit das Logo auf der Willkommensseite nicht in einem
|
||||||
@@ -182,8 +190,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3 text-muted">
|
<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-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>
|
<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>
|
</div>
|
||||||
@@ -247,5 +253,23 @@
|
|||||||
{% include "_footer.html" %}
|
{% include "_footer.html" %}
|
||||||
</div> <!-- /page -->
|
</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -104,8 +104,8 @@
|
|||||||
<li><strong>Medien hochladen:</strong> Im Tab "Medien" eine Datei auswählen und hochladen. Unterstützt werden Bilder (JPG, PNG) und Videos (MP4).</li>
|
<li><strong>Medien hochladen:</strong> Im Tab "Medien" eine Datei auswählen und hochladen. Unterstützt werden Bilder (JPG, PNG) und Videos (MP4).</li>
|
||||||
<li><strong>URL hinzufügen:</strong> Im Tab "Playlist" eine URL eingeben und optional den Zoom-Faktor anpassen.</li>
|
<li><strong>URL hinzufügen:</strong> Im Tab "Playlist" eine URL eingeben und optional den Zoom-Faktor anpassen.</li>
|
||||||
<li><strong>Sortieren:</strong> Per Drag & Drop die Reihenfolge der Playlist-Einträge ändern (Ziehgriff links).</li>
|
<li><strong>Sortieren:</strong> Per Drag & Drop die Reihenfolge der Playlist-Einträge ändern (Ziehgriff links).</li>
|
||||||
<li><strong>Deaktivieren:</strong> Mit der Checkbox einzelne Einträge temporär ausblenden.</li>
|
<li><strong>Deaktivieren:</strong> Mit der Checkbox einzelne Einträge temporär ausblenden. Die Checkbox ist ein reiner Schalter und hat mit der Zeitsteuerung nichts zu tun: Bei einem zeitgesteuerten Eintrag bleibt der Haken gesetzt, auch wenn der Eintrag gerade nicht läuft. Ob er läuft, zeigt das Zeit-Badge – <span class="badge bg-info text-white">Zeit</span> blau = läuft gerade, <span class="badge bg-secondary-lt">Zeit</span> grau = ausserhalb des Zeitfensters. Abgespielt wird nur, was <em>beides</em> erfüllt: Haken gesetzt und im Zeitfenster.</li>
|
||||||
<li><strong>Löschen:</strong> Über den roten Papierkorb-Button.</li>
|
<li><strong>Löschen:</strong> Über den roten Papierkorb-Button. Die Datei wird dabei entfernt <em>und</em> der Eintrag aus der Playlist genommen.</li>
|
||||||
<li><strong>Bearbeiten:</strong> Der <em>Edit</em>-Button öffnet einen Dialog. Bei URLs lassen sich dort Adresse, Zoom, Query-Parameter-Passthrough und Zeitsteuerung ändern; bei Bildern, Videos und HTML-Seiten die Zeitsteuerung.</li>
|
<li><strong>Bearbeiten:</strong> Der <em>Edit</em>-Button öffnet einen Dialog. Bei URLs lassen sich dort Adresse, Zoom, Query-Parameter-Passthrough und Zeitsteuerung ändern; bei Bildern, Videos und HTML-Seiten die Zeitsteuerung.</li>
|
||||||
<li><strong>Zeitsteuerung:</strong> Einträge können nur zu bestimmten Zeiten laufen – siehe <a href="#help-zeitsteuerung">Zeitsteuerung</a>. Ein Badge <span class="badge bg-info text-white">Zeit</span> in der Playlist-Zeile zeigt an, dass ein Eintrag zeitgesteuert ist; der Tooltip nennt die Einstellung im Klartext.</li>
|
<li><strong>Zeitsteuerung:</strong> Einträge können nur zu bestimmten Zeiten laufen – siehe <a href="#help-zeitsteuerung">Zeitsteuerung</a>. Ein Badge <span class="badge bg-info text-white">Zeit</span> in der Playlist-Zeile zeigt an, dass ein Eintrag zeitgesteuert ist; der Tooltip nennt die Einstellung im Klartext.</li>
|
||||||
<li><strong>Query-Parameter-Passthrough:</strong> Per URL-Edit-Button (Stift-Icon) im Playlist-Eintrag kann pro URL gesteuert werden, ob Query-Parameter von der Player-URL an die Playlist-URL übergeben werden. Standardmäßig <strong>aus</strong> – Checkbox "Query-Parameter an diese URL übergeben" aktivieren wenn gewünscht. Ein Link-Icon in der Playlist-Zeile zeigt den Status: <i class="ti ti-link"></i> = an, <i class="ti ti-link-off"></i> = aus.</li>
|
<li><strong>Query-Parameter-Passthrough:</strong> Per URL-Edit-Button (Stift-Icon) im Playlist-Eintrag kann pro URL gesteuert werden, ob Query-Parameter von der Player-URL an die Playlist-URL übergeben werden. Standardmäßig <strong>aus</strong> – Checkbox "Query-Parameter an diese URL übergeben" aktivieren wenn gewünscht. Ein Link-Icon in der Playlist-Zeile zeigt den Status: <i class="ti ti-link"></i> = an, <i class="ti ti-link-off"></i> = aus.</li>
|
||||||
@@ -170,12 +170,14 @@
|
|||||||
<div class="card mb-3">
|
<div class="card mb-3">
|
||||||
<div class="card-header"><h3 class="card-title" id="help-priority"><i class="ti ti-player-play me-1"></i>Priority-Playlist</h3></div>
|
<div class="card-header"><h3 class="card-title" id="help-priority"><i class="ti ti-player-play me-1"></i>Priority-Playlist</h3></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p>Die Priority-Playlist ist <strong>global</strong> und wird auf <em>allen</em> Playern eines Standorts eingeblendet – zwischen den normalen Playlist-Einträgen.</p>
|
<p>Die Priority-Playlist gilt <strong>pro Standort</strong>. Ihre Inhalte werden auf <em>allen</em> Screens dieses Standorts eingeblendet – zwischen den normalen Playlist-Einträgen.</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Aufruf über <code>/admin/<standort>/priority</code></li>
|
<li>Aufruf über <code>/admin/<standort>/priority</code></li>
|
||||||
<li>Globale Medien und URLs verwalten, ebenfalls per Drag & Drop</li>
|
<li>Medien und URLs dieses Standorts verwalten, ebenfalls per Drag & Drop</li>
|
||||||
<li>Priority muss in der Konfiguration aktiviert sein (<code>config.json → priority.enabled</code>)</li>
|
<li>Priority muss für den Standort aktiviert sein (<code>config.json → sites.<standort>.priority.enabled</code>)</li>
|
||||||
<li>Die <a href="#help-zeitsteuerung">Zeitsteuerung</a> steht hier genauso zur Verfügung wie in den Screen-Playlists</li>
|
<li>Die <a href="#help-zeitsteuerung">Zeitsteuerung</a> steht hier genauso zur Verfügung wie in den Screen-Playlists</li>
|
||||||
|
<li>Jeder Standort hat seine <strong>eigene</strong> Priority-Playlist – Inhalte eines Standorts erscheinen nie auf den Screens eines anderen.</li>
|
||||||
|
<li><strong>Vorschau in den Screen-Playlisten:</strong> Die Priority-Einträge werden zusätzlich oberhalb jeder normalen Playlist angezeigt – ausgegraut und mit rotem <span class="badge bg-red text-red-fg">Prio</span>-Badge. Das ist eine <em>reine Anzeige</em>: Sie lässt sich dort nicht sortieren, bearbeiten oder löschen. So ist beim Bearbeiten einer Screen-Playlist sofort sichtbar, was dem Screen ohnehin noch vorweg läuft, ohne dass man in den anderen Menüpunkt wechseln muss. Geändert wird weiterhin ausschliesslich auf der Priority-Seite.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -247,13 +249,15 @@
|
|||||||
<div class="card-header"><h3 class="card-title" id="help-willkommen"><i class="ti ti-users me-1"></i>Willkommensseite</h3></div>
|
<div class="card-header"><h3 class="card-title" id="help-willkommen"><i class="ti ti-users me-1"></i>Willkommensseite</h3></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p>Auf der Willkommensseite können bis zu 3 Kundennamen eingegeben werden. Die Logos werden automatisch via OpenAI + Brandfetch gesucht.</p>
|
<p>Auf der Willkommensseite können bis zu 3 Kundennamen eingegeben werden. Die Logos werden automatisch via OpenAI + Brandfetch gesucht.</p>
|
||||||
|
<p class="text-muted small"><i class="ti ti-lock-open me-1"></i><strong>Ohne Anmeldung nutzbar:</strong> Das Formular kann auch ohne Benutzerkonto aufgerufen werden – etwa am Empfang. Wer angemeldet ist, sieht weiterhin nur die eigenen Standorte; ohne Anmeldung stehen alle zur Auswahl. Im Aktivitätsverlauf erscheinen solche Einträge als „nicht angemeldet".</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Aufruf über <code>/willkommen?site=<standort></code> (oder Menüpunkt)</li>
|
<li>Aufruf über <code>/willkommen?site=<standort></code> (oder Menüpunkt)</li>
|
||||||
<li><strong>Zielscreens:</strong> Die generierte <code>welcome.html</code> wird automatisch an den Anfang der Playlist <em>jedes</em> Screens gesetzt, bei dem im Tab "Einstellungen" die Option <em>Willkommensseite hier anzeigen</em> aktiv ist. So lässt sich dieselbe Begrüßung auf mehreren Screens zeigen, ohne sie mehrfach anzulegen. Ist an einem Standort nichts eingestellt, geht sie wie bisher in die Lobby.</li>
|
<li><strong>Zielscreens:</strong> Die generierte <code>welcome.html</code> wird automatisch an den Anfang der Playlist <em>jedes</em> Screens gesetzt, bei dem im Tab "Einstellungen" die Option <em>Willkommensseite hier anzeigen</em> aktiv ist. So lässt sich dieselbe Begrüßung auf mehreren Screens zeigen, ohne sie mehrfach anzulegen. Ist an einem Standort nichts eingestellt, geht sie wie bisher in die Lobby.</li>
|
||||||
<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>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>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>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>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>
|
<li>Pro Standort kann ein eigenes Hintergrundbild hochgeladen werden (Tab "Medien" im Standort-Dashboard)</li>
|
||||||
<li>Pro Kunde können <strong>Ansprechpartner</strong> eingegeben werden (Markdown-Format):
|
<li>Pro Kunde können <strong>Ansprechpartner</strong> eingegeben werden (Markdown-Format):
|
||||||
@@ -306,6 +310,7 @@
|
|||||||
<li><strong>Sparkline:</strong> Kleine Liniendiagramme zeigen den Verlauf der letzten Änderungen</li>
|
<li><strong>Sparkline:</strong> Kleine Liniendiagramme zeigen den Verlauf der letzten Änderungen</li>
|
||||||
<li><strong>Trend:</strong> Pfeil nach oben/unten mit Netto-Änderung aus den letzten 30 Aktionen</li>
|
<li><strong>Trend:</strong> Pfeil nach oben/unten mit Netto-Änderung aus den letzten 30 Aktionen</li>
|
||||||
<li><strong>Aktivitätsverlauf:</strong> Alle Erstell-, Lösch-, Änderungsaktionen sowie An-/Abmeldungen werden protokolliert</li>
|
<li><strong>Aktivitätsverlauf:</strong> Alle Erstell-, Lösch-, Änderungsaktionen sowie An-/Abmeldungen werden protokolliert</li>
|
||||||
|
<li><strong>Blättern:</strong> Der Verlauf zeigt 50 Einträge je Seite; über die Leiste unter der Liste lässt sich weiter zurückblättern. Aufbewahrt werden die letzten 1000 Einträge. Statistik-Cards, Sparklines und Trendpfeile beziehen sich dabei immer auf die jüngsten 30 Aktionen und ändern sich beim Blättern nicht.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
<input class="form-check-input" type="checkbox" id="remember" name="remember" value="7">
|
<input class="form-check-input" type="checkbox" id="remember" name="remember" value="7">
|
||||||
<label class="form-check-label" for="remember">
|
<label class="form-check-label" for="remember">
|
||||||
Gerät für
|
Gerät für
|
||||||
<select class="form-select form-select-sm d-inline-block w-auto" id="remember_days" onchange="document.getElementById('remember').value=this.value">
|
<select class="form-select form-select-sm d-inline-block w-auto" id="remember_days">
|
||||||
<option value="1">1 Tag</option>
|
<option value="1">1 Tag</option>
|
||||||
<option value="3">3 Tage</option>
|
<option value="3">3 Tage</option>
|
||||||
<option value="7" selected>7 Tage</option>
|
<option value="7" selected>7 Tage</option>
|
||||||
@@ -61,10 +61,13 @@
|
|||||||
var cb = document.getElementById('remember');
|
var cb = document.getElementById('remember');
|
||||||
var sel = document.getElementById('remember_days');
|
var sel = document.getElementById('remember_days');
|
||||||
cb.addEventListener('change', function() {
|
cb.addEventListener('change', function() {
|
||||||
if (this.checked) this.value = sel.value;
|
this.value = sel.value;
|
||||||
});
|
});
|
||||||
|
// Wer im Dropdown eine Dauer waehlt, will das Geraet merken:
|
||||||
|
// Checkbox automatisch mit aktivieren.
|
||||||
sel.addEventListener('change', function() {
|
sel.addEventListener('change', function() {
|
||||||
if (cb.checked) cb.value = this.value;
|
cb.checked = true;
|
||||||
|
cb.value = this.value;
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -472,7 +472,8 @@ function playNext() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Datei aus dem Medienverzeichnis
|
// Datei aus dem Medienverzeichnis
|
||||||
const src = entry.isPrio ? `/media/priority/${item.name}` : `/media/${site}/${basePath}/${item.name}`;
|
// ?v=<mtime> erlaubt dem Browser, die Datei ohne Rueckfrage aus dem Cache zu nehmen
|
||||||
|
const src = `/media/${site}/${basePath}/${item.name}` + (item.v ? `?v=${item.v}` : "");
|
||||||
if (isVideo(item)) {
|
if (isVideo(item)) {
|
||||||
vid.style.display = "block";
|
vid.style.display = "block";
|
||||||
vid.src = src;
|
vid.src = src;
|
||||||
|
|||||||
@@ -147,13 +147,13 @@
|
|||||||
{% if file.type == "image" %}
|
{% if file.type == "image" %}
|
||||||
data-bs-toggle="tooltip"
|
data-bs-toggle="tooltip"
|
||||||
data-bs-html="true"
|
data-bs-html="true"
|
||||||
title="<img src='/media/priority/{{ file.name }}' style='max-width: 200px; max-height: 150px; border: none; background: white; padding: 2px;' alt='Vorschau'>"
|
title="<img src='/media/{{ current_site }}/priority/{{ file.name }}' style='max-width: 200px; max-height: 150px; border: none; background: white; padding: 2px;' alt='Vorschau'>"
|
||||||
{% elif file.type == "url" %}
|
{% elif file.type == "url" %}
|
||||||
data-bs-toggle="tooltip"
|
data-bs-toggle="tooltip"
|
||||||
title="URL: {{ file.name }}{% if file.zoom %} (Zoom: {{ file.zoom }}x){% endif %}"
|
title="URL: {{ file.name }}{% if file.zoom %} (Zoom: {{ file.zoom }}x){% endif %}"
|
||||||
{% endif %}>
|
{% endif %}>
|
||||||
{{ file.name }}{% if file.type == "url" and file.zoom != 1.0 %} <strong style="color: #DA002D;">[{{ file.zoom }}x]</strong>{% endif %}{% if file.type == "url" %}{% if file.get('passthrough', False) %} <i class="ti ti-link" style="color:#6c757d;" title="Query-Parameter werden übergeben"></i>{% else %} <i class="ti ti-link-off" style="color:#adb5bd;" title="Query-Parameter werden nicht übergeben"></i>{% endif %}{% endif %}
|
{{ file.name }}{% if file.type == "url" and file.zoom != 1.0 %} <strong style="color: #DA002D;">[{{ file.zoom }}x]</strong>{% endif %}{% if file.type == "url" %}{% if file.get('passthrough', False) %} <i class="ti ti-link" style="color:#6c757d;" title="Query-Parameter werden übergeben"></i>{% else %} <i class="ti ti-link-off" style="color:#adb5bd;" title="Query-Parameter werden nicht übergeben"></i>{% endif %}{% endif %}
|
||||||
{% if file.schedule %}<span class="badge bg-info text-white ms-1" title="{{ file.schedule | schedule_text }}">Zeit</span>{% endif %}
|
{% if file.schedule %}<span class="badge ms-1 {% if file.active %}bg-info text-white{% else %}bg-secondary-lt{% endif %}" title="{{ file.schedule | schedule_text }}{% if not file.active %} — laeuft gerade nicht{% endif %}">Zeit</span>{% endif %}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{% if file.type == "image" %}
|
{% if file.type == "image" %}
|
||||||
|
|||||||
16
users.json
16
users.json
@@ -19,7 +19,6 @@
|
|||||||
"trusted_devices": []
|
"trusted_devices": []
|
||||||
},
|
},
|
||||||
"erik.thiele@cancom.de": {
|
"erik.thiele@cancom.de": {
|
||||||
"password_hash": "scrypt:32768:8:1$D8XYprsEYMO5EPrG$2829b18f77bbd7d9f0cbb945b0bdbc32e16d7889f8711881f82dd35accd26ddb7c31d3fa85e3b89d19d6d1f0b92d8d18765e1c82044188cab8fd0db2d35a0d15",
|
|
||||||
"role": "admin",
|
"role": "admin",
|
||||||
"sites": [],
|
"sites": [],
|
||||||
"must_change_password": false,
|
"must_change_password": false,
|
||||||
@@ -27,24 +26,13 @@
|
|||||||
"last_name": "Thiele",
|
"last_name": "Thiele",
|
||||||
"department": "CSO",
|
"department": "CSO",
|
||||||
"notes": "Standort Stuttgart",
|
"notes": "Standort Stuttgart",
|
||||||
"mfa_secret": "LR5DYI3SQPMW5XWKO33HPETHY5TPOWRG",
|
|
||||||
"mfa_enabled": true,
|
|
||||||
"mfa_recovery_codes": [
|
|
||||||
"7BBD93",
|
|
||||||
"EB1D3B",
|
|
||||||
"BEE291",
|
|
||||||
"D471D5",
|
|
||||||
"D43C00",
|
|
||||||
"75DBAC",
|
|
||||||
"8B00CB",
|
|
||||||
"7AF72D"
|
|
||||||
],
|
|
||||||
"trusted_devices": [
|
"trusted_devices": [
|
||||||
{
|
{
|
||||||
"token": "5kMgj4JPxd2IHuzV5MagAAcYyi_DBImyGimBSvZUNVg",
|
"token": "5kMgj4JPxd2IHuzV5MagAAcYyi_DBImyGimBSvZUNVg",
|
||||||
"expires": "2026-07-24T15:40:28.955130"
|
"expires": "2026-07-24T15:40:28.955130"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"password_hash": "scrypt:32768:8:1$9k9WAwHOnYTdKY4Q$869e9bec6a838c212543a228edc7a516933b37b9494bb1d10424f18adef3ea132d4c340ab939ca023c22ab0d4def13ad64c44a648f64ce519c2097adc0537f92"
|
||||||
},
|
},
|
||||||
"test1@test.de": {
|
"test1@test.de": {
|
||||||
"password_hash": "scrypt:32768:8:1$35kBhL79TJyVA3Rz$98b2e269db5ae96c9285323a274a0cc5b199fb45078e7530b5e72cdb9b96d5b522aa18fd06ce0f801bd19ec68ed323ffa04ce484cfb849eba73b9260ca6a50e1",
|
"password_hash": "scrypt:32768:8:1$35kBhL79TJyVA3Rz$98b2e269db5ae96c9285323a274a0cc5b199fb45078e7530b5e72cdb9b96d5b522aa18fd06ce0f801bd19ec68ed323ffa04ce484cfb849eba73b9260ca6a50e1",
|
||||||
|
|||||||
Reference in New Issue
Block a user