Compare commits
44 Commits
29caaa2e0e
...
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 | ||
|
|
9dae841461 | ||
|
|
048f2499d8 | ||
|
|
3844ceacf2 | ||
|
|
64db742d31 | ||
|
|
4974ad92df | ||
|
|
94bfe865ef | ||
|
|
03d663588c | ||
|
|
848bcf93ab | ||
|
|
3761497421 | ||
|
|
5dda8abe2e | ||
|
|
90baa6130f |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -5,6 +5,8 @@ test*
|
||||
.venv/
|
||||
.DS_Store
|
||||
.vscode
|
||||
.env
|
||||
env.sh
|
||||
media/
|
||||
*.mp4
|
||||
*.mov
|
||||
|
||||
67
AGENTS.md
67
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>/priority` | Priority-Playlist als separate Seite |
|
||||
| `GET /media/<site>/<screen>/<file>` | Medien-Datei ausliefern |
|
||||
| `GET /media/priority/<file>` | Priority-Medien (global) |
|
||||
| `GET /media/<site>/priority/<file>` | Priority-Medien des Standorts |
|
||||
| `GET /media/<site>/background/<filename>` | Hintergrundbild der Willkommensseite |
|
||||
| `GET /playlist/<site>/<screen>/hash` | Playlist-Checksumme für Auto-Reload + zeichnet Heartbeat auf (Online-Status) |
|
||||
| `GET /willkommen?site=<site>` | Willkommensseite für Standort generieren |
|
||||
@@ -107,19 +107,30 @@ Standorte (sites) sind die oberste Organisationsebene und gruppieren Screens.
|
||||
|
||||
- `GET /player/<site>/<screen>` renders the playlist and auto-reloads from `/playlist/<site>/<screen>/hash`.
|
||||
- `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.
|
||||
- URL playlist items are stored as dicts like `{"url": "https://...", "zoom": 0.8}` and the zoom value must survive save/reorder flows. File items may also be dicts (`{"name": "bild.jpg", ...}`); `save_playlist` copies existing dicts, so extra keys like `schedule` survive reordering.
|
||||
- `.html` items in `media/` are rendered inline as content, not in an iframe.
|
||||
- `config.priority.enabled` makes the priority playlist show on every screen.
|
||||
- `POST /api/customer` generates `welcome.html` and inserts it at the front of the lobby playlist for the specified site.
|
||||
- `sites.<site>.priority.enabled` makes the priority playlist show on every screen **of that site**.
|
||||
- `POST /api/customer` generates `welcome.html` and inserts it at the front of the playlist of every target screen for the specified site. Target screens are those with `welcome_enabled`; if no screen of the site has the key at all, the old behaviour applies (`lobby` only). Optional `valid_until` (`"YYYY-MM-DD"`) attaches a schedule so the page disappears on its own.
|
||||
- New standorte can be added by creating `media/<neuer-standort>/<screen>/` directories and optionally adding config to `config.json["sites"][<neuer-standort>]`.
|
||||
- Priority playlist (`config.priority`) is global and affects all sites/screens.
|
||||
- **Die Priority-Playlist gilt je Standort** (`sites.<site>.priority`), nicht global. Frueher lag sie unter `config.priority` und wirkte auf alle Standorte - dadurch lief Stuttgarter Inhalt auch in Karlsruhe. `get_priority_config(cfg, site)` liefert die Konfiguration des Standorts und uebernimmt einen globalen Altbestand beim ersten Zugriff einmalig (mit `deepcopy`, damit Aenderungen nicht zwischen Standorten durchschlagen).
|
||||
- Priority-Medien liegen unter `media/<site>/priority/`, ausgeliefert ueber `/media/<site>/priority/<datei>`. Die Ordnernamen `priority` und `logos` sind in der Screen-Erkennung ausgenommen - sonst wuerden sie als Screens in der Konfiguration landen.
|
||||
- Willkommensseite (`customer.html`) accepts up to 3 customer names; logos are fetched via OpenAI→Brandfetch and displayed in a flex row (portrait: column).
|
||||
- `generate_welcome_html(customer_names, logo_urls, contacts=None, site="stuttgart", background_url=None)` takes lists for up to 3 customers; logos have equal width (280px) with fixed height 120px, text left-aligned below each logo.
|
||||
- Contact info supports Markdown: `**bold**`, `*italic*`, `` `code` ``, `- list items`, and line breaks (preserved as `<br>`).
|
||||
- `customer_names` are preserved in form fields after POST (via `value`-Attribute).
|
||||
- Admin-UI nutzt keyadmin-Design: `brand-surface` (#2b2f36), `nav-surface` (rot #DA002D), Dark Mode per `localStorage("signage-theme")`.
|
||||
- Gemeinsame HTML-Bausteine: `_header.html`, `_footer.html`, `_styles.html` (CSS-Variablen `--ccm-*`, Dark Mode, Card-Border-Radius 1rem).
|
||||
- `add_customer_to_lobby_playlist` entfernt `welcome.html` sowohl als String als auch als Dict aus der Playlist vor dem Einfügen.
|
||||
- `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.
|
||||
- **Logo-Upload**: Pro Kunde kann im Willkommensseiten-Formular eine Bilddatei hochgeladen werden (`logo_file_1..3`); ein Upload hat Vorrang vor `search_customer_logo`. Ablage als `media/<site>/logo_<slot>.png`, Auslieferung über `/media/<site>/logos/<datei>` (die Route liest aus dem Standort-Verzeichnis).
|
||||
- **Keine neuen Unterordner unter `media/<site>/` anlegen.** `admin()` behandelt jeden Unterordner dort als Screen und persistiert ihn ueber `get_screen_config()` in der Konfiguration - ein Ordner `logos` erschien dadurch als Phantom-Screen, bekam eine `welcome.html` und liess sich ueber die Admin-UI bespielen und loeschen. Deshalb liegen Logos wie das Hintergrundbild als Dateien direkt im Standort-Verzeichnis. Der Name `logos` ist in der Screen-Erkennung zusaetzlich ausgenommen, damit Altbestaende nicht wieder auftauchen. Die URL traegt einen `?v=<timestamp>`, damit Player kein altes Logo aus dem Cache zeigen.
|
||||
- Nach dem Freistellen schneidet `trim_transparent_border()` die durchsichtigen Raender weg. Das ist kein Schoenheitsdetail: `.customer-logo` skaliert mit `object-fit: contain` auf 120px Hoehe, ein breiter Rand zaehlt dabei mit und laesst ein hochgeladenes Logo deutlich kleiner erscheinen als ein eng beschnittenes Brandfetch-Logo (gemessen: 30px statt 120px). Der Schnitt greift auch bei bereits transparenten PNGs, aber nicht bei abgewaehltem Freistellen - dort gibt es keinen transparenten Rand.
|
||||
- `remove_logo_background()` arbeitet mit Pillow (Flood Fill von den Raendern, weiche Kante). **Bewusst kein ML-Modell** wie rembg: der Container laeuft mit read-only Root-FS ohne Modell-Cache. Flood Fill statt globalem Farbvergleich ist wesentlich - sonst verschwindet auch Weiss *innerhalb* des Logos. Bereits transparente Bilder werden unveraendert durchgereicht; bei unruhigem Bildrand bleibt das Bild unangetastet und die Funktion gibt einen Hinweis zurueck. **Ebenso, wenn nach dem Fuellen weniger als 2% sichtbar waeren**: ein helles Logo auf hellem Grund liegt innerhalb der Toleranz, der Flood Fill frisst es mit auf und das Ergebnis waere ein leeres PNG - auf der Willkommensseite also gar kein Logo. In dem Fall wird die vor dem Eingriff angelegte Kopie zurueckgegeben.
|
||||
- `welcome_data` in der Site-Config wird **feldweise** gespeichert (Slot 1-3, auch leere), nicht nur die erfolgreichen Kunden - sonst verschiebt sich die Zuordnung von Name, Logo und Ansprechpartner beim naechsten Aufruf. Ein zuvor hochgeladenes Logo wird beim erneuten Generieren wiederverwendet, solange der Kundenname im selben Feld unveraendert ist.
|
||||
- **Der Schalter `welcome_enabled` wirkt sofort**: `update_screen` ruft bei einer Aenderung `sync_welcome_for_screen()`, das die vorhandene `welcome.html` in den Screen kopiert und vorne einfuegt bzw. beim Abschalten Eintrag und Datei entfernt. Kein erneutes Generieren noetig - die Seite ist je Standort identisch. Die Funktion aendert nur das uebergebene config-Dict, gespeichert wird vom Aufrufer.
|
||||
- `save_welcome_page` legt zusaetzlich eine Standort-Kopie unter `media/<site>/welcome.html` an, die **nie geloescht** wird. Ohne sie waere nach dem Abschalten aller Screens keine Quelle mehr da und der Schalter wuerde stillschweigend nichts tun.
|
||||
- Beim Generieren wird `welcome.html` aus abgewaehlten Screens auch als **Datei** entfernt, nicht nur aus der Playlist: bei leerer Playlist spielt `player()` sonst noch nicht verzeichnete Dateien aus dem Ordner ab.
|
||||
- Weder `save_welcome_page` noch `add_customer_to_playlists` legen Screens an. Das frühere `screens.setdefault("lobby", {})` erzeugte an Standorten ohne Lobby stillschweigend einen Screen.
|
||||
- `add_screen` legt ein Verzeichnis unter `media/<site>/<screen>/` an und einen Config-Eintrag.
|
||||
- `delete_screen` entfernt den Screen aus der Config und löscht das Verzeichnis rekursiv.
|
||||
- Custom-URL-Button: pro Screen konfigurierbar (`custom_url` + `custom_url_label` + `custom_url_enabled` + `custom_url_target` + `custom_url_position`) im Admin-Formular (Aktionen-Tab); wird im Player als Button an wählbarer Position (9 Positionen: oben/mitte/unten × links/mitte/rechts) angezeigt und öffnet die URL wahlweise in einem iframe-Overlay mit Zurück-Button (`overlay`) oder per Direkt-Weiterleitung (`redirect`); Player pausiert während das Overlay geöffnet ist. Die unteren Positionen weichen automatisch 16px über dem Newsticker-Balken aus.
|
||||
@@ -133,6 +144,7 @@ Standorte (sites) sind die oberste Organisationsebene und gruppieren Screens.
|
||||
- `delete_site` entfernt den Standort aus Config und löscht das Medienverzeichnis rekursiv (nur Admins).
|
||||
- `config.json["server_url"]` (z. B. `http://signage.ccmake.de`) wird in der Admin-Ansicht für die Player-URLs verwendet.
|
||||
- **Query-Parameter-Passthrough**: Query-Parameter an der Player-URL (`/player/<site>/<screen>`) können pro Playlist-URL im Edit-Modal (Checkbox) gesteuert werden. Standardmäßig **aus** – nur URLs mit aktivierter Checkbox erhalten die Parameter. Beispiel: `/player/stuttgart/lobby?customer=acme` hängt `?customer=acme` nur an URLs mit Passthrough=an. In der Playlist-Zeile zeigt ein Link-Icon (`ti-link`, grau) den Status an: sichtbar = an, durchgestrichen (`ti-link-off`, hellgrau) = aus. Funktioniert über `appendParams()` in `player.html` + `player_params` im Flask-Route.
|
||||
- **Zeitsteuerung**: Optionales `schedule`-Objekt pro Playlist-Eintrag (URL *und* Datei, Screen-Playlist *und* Priority). Ausgewertet ausschliesslich serverseitig in `is_item_active()`; siehe Abschnitt "Zeitsteuerung" unten.
|
||||
- Screen-Card-Body hat Tabler-Tabs: **Playlist** (1, aktiv), **Einstellungen** (2), **Aktionen** (3), **Digital Voice Agent** (4), **Medien** (5), **Info** (6); Priority-Seite ebenfalls Tabs **Playlist** und **Medien**.
|
||||
- `stay_on_first`: Wenn aktiviert bleibt der Player auf dem ersten Playlist-Element stehen (kein Durchlauf).
|
||||
- Tab-Reihenfolge in Screen-Cards: Playlist → Einstellungen → Aktionen → Digital Voice Agent → Medien → Info.
|
||||
@@ -142,13 +154,22 @@ Standorte (sites) sind die oberste Organisationsebene und gruppieren Screens.
|
||||
- User-Dropdown im Header: zeigt E-Mail + Role-Badge, Menü mit Passwort ändern, MFA einrichten (mit "Aktiv"-Badge wenn MFA eingeschaltet), Userverwaltung (Admin), Abmelden.
|
||||
- Info-Tab in Screen-Cards: zeigt Client-Info-Tabelle (IP, Browser, Auflösung, zuletzt gesehen) aus dem Heartbeat-JSON für den jeweiligen Screen
|
||||
|
||||
- **Medien-Cache**: `player()` gibt jedem Datei-Eintrag `v` (mtime) mit, der Player haengt `?v=<mtime>` an. `send_media()` setzt dann `max-age` 1 Jahr + `immutable`; ohne `?v` bleibt Flasks `no-cache`. Eine ersetzte Datei bekommt so automatisch eine neue URL. Grosse Videos cacht der Browser trotzdem oft nicht (eigener Media-Cache) - dafuer braeuchte es einen Service Worker.
|
||||
|
||||
## Betrieb / Diagnose
|
||||
|
||||
- `docker compose up -d` startet nur neu und **baut nicht**. Nach Code-Aenderungen `--build` verwenden, sonst laeuft weiter das alte Image - das hat bei der Fehlersuche mehrfach zu falschen Schluessen gefuehrt.
|
||||
- Das Dockerfile setzt `PYTHONUNBUFFERED=1`. Ohne das puffert Python `stdout` blockweise (kein TTY) und **keine** `print()`-Ausgabe der App erscheint in `docker compose logs`. Tracebacks waren sichtbar, weil Flask sie ueber stderr schreibt - das machte den blinden Fleck schwer erkennbar.
|
||||
- gunicorn laeuft mit `--access-logfile -`, protokolliert also jeden Request. Ohne das laesst sich nicht feststellen, ob eine Anfrage den Container ueberhaupt erreicht.
|
||||
- `add_customer` protokolliert je Kundenfeld `Upload = <dateiname>` bzw. `Upload = keiner`. Ein geleertes `<input type="file">` - etwa nach einem Reload der Seite - sieht serverseitig sonst genauso aus wie "kein Logo hochgeladen".
|
||||
|
||||
## Repo Quirks
|
||||
|
||||
- `media/` and media file extensions are gitignored.
|
||||
- `users.json` is NOT gitignored (tracked for initial admin setup, contains no secrets by default).
|
||||
- The app has no configured tests, lint, typecheck, formatter, or CI.
|
||||
- Hardcoded secrets exist in tracked files; do not commit new secrets or reshuffle them casually.
|
||||
- Wichtige Helper-Funktionen in `app.py`: `load_config()`, `save_config()`, `get_site_list()`, `get_screen_config()`, `is_url()`, `normalize_url()`, `playlist_item_name()`, `playlist_item_enabled()`, `load_priority_files()`, `prio_redirect()`, `get_background_url()`, `load_users()`, `save_users()`, `get_user()`, `init_user_db()`, `get_accessible_sites()`, `record_heartbeat()`, `screen_is_active()`, `get_client_info()`, `add_history_entry()`.
|
||||
- Wichtige Helper-Funktionen in `app.py`: `load_config()`, `save_config()`, `get_site_list()`, `get_screen_config()`, `is_url()`, `normalize_url()`, `playlist_item_name()`, `is_item_active()`, `_schedule_time()`, `_window_start_date()`, `_in_validity_range()`, `load_priority_files(site)`, `get_priority_config(cfg, site)`, `priority_dir(site)`, `prio_redirect()`, `get_background_url()`, `load_users()`, `save_users()`, `get_user()`, `init_user_db()`, `get_accessible_sites()`, `record_heartbeat()`, `screen_is_active()`, `get_client_info()`, `add_history_entry()`.
|
||||
- Zugriffs-Dekoratoren in `app.py`: `admin_required`, `site_access_required`.
|
||||
- Hintergrundbild der Willkommensseite wird pro Standort unter `media/<site>/background.*` gespeichert; Fallback auf `static/wallpaper.png` wenn keine Datei existiert.
|
||||
- `get_background_url(site)` prüft auf benutzerdefiniertes Hintergrundbild für einen Standort.
|
||||
@@ -158,6 +179,38 @@ Standorte (sites) sind die oberste Organisationsebene und gruppieren Screens.
|
||||
- `admin_priority` rendert `priority.html` mit `site_list`, `current_site`, `priority_files`, `server_url`.
|
||||
- `static/dva.png`: Bild für den Voice-Agent-Button (optional, per Admin-Toggle ein-/ausblendbar).
|
||||
|
||||
## Zeitsteuerung (seit v6.2.0)
|
||||
|
||||
Jeder Playlist-Eintrag kann ein optionales `schedule`-Objekt tragen — URL wie Datei, in Screen-Playlists wie in der Priority-Playlist.
|
||||
|
||||
```jsonc
|
||||
{ "mode": "daily", "start": "08:00", "end": "12:00",
|
||||
"valid_from": "2026-09-10", "valid_until": "2026-09-20" } // valid_* optional
|
||||
{ "mode": "weekly", "start": "09:00", "end": "17:00", "days": ["0", "2", "4"] }
|
||||
{ "mode": "once", "start": "2026-09-10T08:00", "end": "2026-09-20T12:00" }
|
||||
```
|
||||
|
||||
### Regeln
|
||||
|
||||
- `once` vergleicht vollständige Zeitstempel. `daily`/`weekly` werten **nur die Uhrzeit** aus; ein Datum wird dort ausschliesslich über `valid_from`/`valid_until` gesetzt (beide optional, beide inklusiv).
|
||||
- Bei `daily` gilt **jeder** Wochentag; ein vorhandenes `days` wird in diesem Modus nicht ausgewertet (bleibt aber erhalten, damit ein Moduswechsel die Auswahl nicht vernichtet).
|
||||
- `start` > `end` bedeutet ein Fenster **über Mitternacht**. Ein solches Fenster gehört zum Wochentag *und* Gültigkeitstag seines **Starts** — deshalb liefert `_window_start_date()` das Datum des Fensterbeginns und nicht einfach `now.date()`.
|
||||
- Unlesbare oder unvollständige Zeitangaben geben `True` zurück. Ein Konfigurationsfehler soll den Screen nicht stillschweigend leeren — beim Ändern dieser Logik unbedingt beibehalten.
|
||||
- `_schedule_time()` liest `"HH:MM"`, `"HH:MM:SS"` und volle ISO-Zeitstempel. Letzteres ist nötig, weil ältere Versionen bei `daily`/`weekly` einen kompletten Zeitstempel gespeichert haben, dessen Datumsanteil ignoriert wurde.
|
||||
- Auswertung erfolgt **nur serverseitig** (`datetime.now()` des Servers, nicht des Players).
|
||||
|
||||
### Reload-Kopplung
|
||||
|
||||
`playlist_hash()` hasht neben der Konfiguration auch `[is_item_active(item) for item in ...]` für Screen- und Priority-Playlist. Ohne das würde der Player ein ablaufendes Zeitfenster erst beim nächsten Reload aus anderem Grund bemerken, da sich die Konfiguration dabei nicht ändert.
|
||||
|
||||
### Beteiligte Stellen
|
||||
|
||||
- `is_item_active()`, `_window_start_date()`, `_in_validity_range()`, `_schedule_time()` in `app.py`
|
||||
- Jinja-Filter `schedule_text()` für den Tooltip am "Zeit"-Badge
|
||||
- `add_url()` liest bei `daily`/`weekly` die Felder `schedule_start_time`/`schedule_end_time`, bei `once` `schedule_start`/`schedule_end`
|
||||
- `update_playlist_item()` behandelt `new_url: null` als Datei-Eintrag und schreibt `{"name": ...}`; `schedule: {}` entfernt die Zeitsteuerung, ein fehlender Key behält sie
|
||||
- UI in `admin.html` und `priority.html`: zwei Feldpaare (`datetime-local` für `once`, `time` für `daily`/`weekly`), per Modus umgeschaltet
|
||||
|
||||
## Heartbeat / Online-Status (seit v6.1.0)
|
||||
|
||||
Der Player-Status (Online/Offline) wird mittels dateibasiertem Heartbeat ermittelt.
|
||||
|
||||
11
DESIGN.md
11
DESIGN.md
@@ -141,6 +141,7 @@ The palette is rooted in CANCOM's corporate red and high-contrast neutrals.
|
||||
- **Muted (#6B7280 / #9AA4B2 Dark):** Secondary text, captions, metadata, placeholders.
|
||||
- **Online/Offline:** Green (#2FB344) for online status badge, Red (#D63939) for offline.
|
||||
- **Role Badges:** Red for Admin, Purple (#8B5CF6) for Superuser, Blue (#3B82F6) for User.
|
||||
- **Schedule Badge:** Bootstrap `bg-info` with white text, label "Zeit", marks a scheduled playlist item; the tooltip carries the plain-text schedule from the `schedule_text` filter.
|
||||
- **Newsticker:** Red bar (#DA002D) with white text.
|
||||
|
||||
### States
|
||||
@@ -194,7 +195,9 @@ Screen management cards have:
|
||||
|
||||
### Grid Areas
|
||||
|
||||
The playlist row uses a fixed grid: `28px 1fr 90px 80px 90px 32px` for checkbox, name, type badge, size, delete button, drag handle.
|
||||
The playlist row uses a fixed grid: `28px 1fr 90px 80px 50px 40px 32px` for checkbox, name, type badge, size, delete button, edit button, drag handle — identical in `admin.html` and `priority.html`.
|
||||
|
||||
**The column count is fixed.** Anything added to a row becomes an extra grid item and shifts every following column. Status markers therefore live *inside* the `.playlist-name` span, not as siblings — this applies to the passthrough link icon and the "Zeit" badge.
|
||||
|
||||
## Elevation & Depth
|
||||
|
||||
@@ -250,6 +253,10 @@ Red background (`--ccm-primary`). Contains white outline buttons (`btn-white`):
|
||||
- Body contains Tabler tabs (6 tabs for screen cards)
|
||||
- Tab background matches card surface; tab link text uses muted color; active tab uses surface background
|
||||
|
||||
**`.card` is styled globally**, so the red left accent and grey header apply to *every* element carrying the class. The card is the container for a top-level entity — a screen, a help topic, the priority playlist. It is not a way to group fields.
|
||||
|
||||
Grouping form fields inside a modal or a tab uses plain rows (`mb-3`, `form-check`) with an `<hr>` and an `<h3>` where a separator is wanted. A `.card` there reads as a foreign tile inside the form and violates the one-red-accent-per-card rule.
|
||||
|
||||
### Player Media Elements
|
||||
|
||||
- img, video, iframe: fullscreen (`100vw × 100vh`), fixed position, centered via `top:50%; left:50%; transform:translate(-50%,-50%)`
|
||||
@@ -323,6 +330,8 @@ Same base as action button plus:
|
||||
- Do keep player pages completely chromeless — no headers, navs, or footers
|
||||
- Don't add shadows to cards; use border + left accent instead
|
||||
- Don't use more than one red accent per card (the 6px left border is sufficient)
|
||||
- Don't use `.card` to group form fields inside a modal or tab — it inherits the global red accent and grey header; use plain rows with `<hr>` + `<h3>` instead
|
||||
- Don't add elements as direct children of a playlist row — the grid has a fixed column count; put status markers inside `.playlist-name`
|
||||
- Don't mix corner radii: cards use 1rem, player buttons use 20px — keep each category consistent
|
||||
- Do set `data-bs-theme` synchronously before rendering to prevent dark-mode flash
|
||||
- Do persist theme preference in `localStorage("signage-theme")`
|
||||
|
||||
@@ -14,7 +14,12 @@ RUN addgroup --system --gid 1000 app && \
|
||||
|
||||
USER app
|
||||
ENV HOME=/tmp
|
||||
# Ohne das puffert Python stdout blockweise, weil es kein Terminal ist -
|
||||
# die print()-Ausgaben der App tauchen dann nicht in "docker compose logs" auf.
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
EXPOSE 5005
|
||||
|
||||
CMD ["gunicorn", "-b", "0.0.0.0:5005", "-w", "4", "app:app"]
|
||||
# --access-logfile - schreibt jeden Request nach stdout. Ohne das protokolliert
|
||||
# gunicorn keine Zugriffe, und man sieht nicht, ob eine Anfrage ueberhaupt ankommt.
|
||||
CMD ["gunicorn", "-b", "0.0.0.0:5005", "-w", "4", "--access-logfile", "-", "app:app"]
|
||||
|
||||
177
README.md
177
README.md
@@ -9,8 +9,8 @@ Browserbasiertes Digital-Signage-System für interne Info-Screens.
|
||||
- Browserbasierter Player (Kiosk-Modus, Chromium, Firefox)
|
||||
- Bilder, Videos (MP4/H.264), HTML-Seiten, URL-Playlist-Einträge
|
||||
- **Multi-Standort**: Standorte (sites) gruppieren Screens
|
||||
- **Priority-Playlist**: globale Inhalte wirken auf alle Player
|
||||
- **Willkommensseite**: bis zu 3 Kundenlogos via OpenAI + Brandfetch, pro Standort konfigurierbares Hintergrundbild
|
||||
- **Priority-Playlist**: pro Standort; die Inhalte laufen auf allen Screens **dieses** Standorts, nicht standortübergreifend. Sie werden zusätzlich als **reine Vorschau** oberhalb jeder Screen-Playlist angezeigt (grau, mit „Prio"-Badge), damit sie beim Bearbeiten nicht übersehen werden – bedient wird weiterhin nur auf der Priority-Seite
|
||||
- **Willkommensseite** (ohne Login erreichbar): bis zu 3 Kundenlogos via OpenAI + Brandfetch oder **manueller Upload mit automatischem Freistellen** (transparenter Hintergrund), pro Standort konfigurierbares Hintergrundbild; erscheint auf allen Screens mit `welcome_enabled` (mehrere möglich), optional mit Ablaufdatum
|
||||
- Pro Screen eigene Playlist mit Drag-&-Drop-Reihenfolge
|
||||
- Auto-Reload bei Playlist-Änderungen
|
||||
- Newsticker pro Screen
|
||||
@@ -22,9 +22,10 @@ Browserbasiertes Digital-Signage-System für interne Info-Screens.
|
||||
- **User-Verwaltung**: Mehrere User mit Rollen (Admin / Superuser / User), E-Mail als Login, Passwort-Hashing (scrypt), Berechtigungen pro Standort
|
||||
- **Passwort-Workflow**: First-Login-Änderung, Admin-Reset mit temporärem Passwort, Notfall-Login bei fehlendem Hash
|
||||
- **MFA (Multi-Faktor)**: Optionale TOTP-Authentifizierung (Google Authenticator, Authy) per User aktivierbar; QR-Code-Scan + Code-Verifikation, 8 Recovery-Codes, Deaktivierung mit Passwort-Bestätigung, **Trusted Device** (Gerät für 1–30 Tage speichern, überspringt MFA beim nächsten Login)
|
||||
- **Admin Dashboard**: `/admin/dashboard` – Statistiken (Sites, Screens, User, Admins, Superuser) mit Sparkline-Charts + Trendanzeige, Aktivitätsverlauf
|
||||
- **Aktivitätsverlauf**: Alle Erstell-/Lösch-/Änderungsaktionen sowie Login/Logout werden in `history.json` protokolliert und im Dashboard angezeigt
|
||||
- **Admin Dashboard**: `/admin/dashboard` – Statistiken (Sites, Screens, User, Admins, Superuser) mit Sparkline-Charts + Trendanzeige, Aktivitätsverlauf mit Seitennavigation
|
||||
- **Aktivitätsverlauf**: Alle Erstell-/Lösch-/Änderungsaktionen sowie Login/Logout werden in `history.json` protokolliert (bis zu 1000 Einträge) und im Dashboard seitenweise angezeigt (50 je Seite)
|
||||
- Tab-basierte Admin-UI pro Screen: Playlist, Einstellungen, Aktionen, Digital Voice Agent, Medien, Info (Tabler Tabs)
|
||||
- **Zeitsteuerung**: Pro Playlist-Eintrag (URL *und* Datei, auch in der Priority-Playlist) ein Zeitfenster festlegen – einmalig (Datum + Uhrzeit), täglich oder wöchentlich (Uhrzeit + optionaler Gültigkeitszeitraum, wöchentlich zusätzlich mit Wochentagen). Fenster über Mitternacht werden unterstützt. Ein "Zeit"-Badge in der Playlist-Zeile zeigt die Einstellung im Tooltip
|
||||
- **Query-Parameter-Passthrough**: Per Player-URL übergebene Query-Parameter (z. B. `/player/stuttgart/lobby?customer=acme`) können pro Playlist-URL im Edit-Modal aktiviert werden – standardmäßig aus. Link-Icon in der Playlist-Zeile zeigt Status (an/aus) an.
|
||||
- Priority-Seite ebenfalls mit Tabs: Playlist und Medien
|
||||
- Dark Mode (localStorage-persistiert)
|
||||
@@ -52,7 +53,7 @@ Flask App (Server)
|
||||
│ │ │ ├── bild.jpg
|
||||
│ │ │ ├── video.mp4
|
||||
│ │ │ └── welcome.html
|
||||
│ └── priority/
|
||||
│ │ └── priority/
|
||||
└── generate_welcome_page.py
|
||||
```
|
||||
|
||||
@@ -74,10 +75,13 @@ signage/
|
||||
├── history.json # Aktivitätsverlauf (Dashboard)
|
||||
├── media/
|
||||
│ ├── <site>/
|
||||
│ │ ├── lobby/
|
||||
│ │ ├── lobby/ # Screen-Ordner (jeder Unterordner = ein Screen!)
|
||||
│ │ ├── casino/
|
||||
│ │ └── videosysteme/
|
||||
│ └── priority/
|
||||
│ │ ├── videosysteme/
|
||||
│ │ ├── background.jpg # Hintergrundbild der Willkommensseite (optional)
|
||||
│ │ ├── priority/ # Priority-Medien dieses Standorts
|
||||
│ │ ├── logo_1.png # hochgeladene Kundenlogos (Slot 1-3)
|
||||
│ │ └── welcome.html # Standort-Kopie der Willkommensseite
|
||||
├── templates/
|
||||
│ ├── admin.html # Admin-Dashboard (Übersicht)
|
||||
│ ├── admin_dashboard.html # Admin Dashboard (Statistiken + Verlauf)
|
||||
@@ -174,6 +178,25 @@ docker compose up -d
|
||||
|
||||
Server läuft auf `http://localhost:5005` (gunicorn mit 4 Workern, non-root User).
|
||||
|
||||
**Wichtig:** `docker compose up -d` startet nur neu, es baut nicht. Nach Code-Änderungen:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
### Logs
|
||||
|
||||
```bash
|
||||
docker compose logs -f signage
|
||||
```
|
||||
|
||||
Der Container ist so eingestellt, dass dort alles Nötige ankommt:
|
||||
|
||||
- `ENV PYTHONUNBUFFERED=1` im Dockerfile – ohne das puffert Python `stdout` blockweise, sobald es kein Terminal ist, und sämtliche `print()`-Ausgaben der App bleiben unsichtbar.
|
||||
- `gunicorn --access-logfile -` – protokolliert jeden Request. Ohne das ist nicht einmal erkennbar, ob eine Anfrage den Container überhaupt erreicht.
|
||||
|
||||
Beides fehlte anfangs und machte die Fehlersuche unnötig schwer.
|
||||
|
||||
---
|
||||
|
||||
## Routen
|
||||
@@ -184,7 +207,7 @@ Server läuft auf `http://localhost:5005` (gunicorn mit 4 Workern, non-root User
|
||||
| `GET /login` | Admin-Login |
|
||||
| `GET /logout` | Ausloggen |
|
||||
| `GET /admin` | Redirect zum ersten konfigurierten Standort |
|
||||
| `GET /admin/dashboard` | Admin Dashboard (Statistiken + Verlauf) |
|
||||
| `GET /admin/dashboard` | Admin Dashboard (Statistiken + Verlauf, `?page=<n>`) |
|
||||
| `GET /admin/<site>` | Admin-Dashboard für einen Standort |
|
||||
| `GET /admin/<site>/priority` | Priority-Playlist (separate Seite) |
|
||||
| `POST /admin/<site>/update/<screen>` | Allgemeine Screen-Einstellungen speichern (Intervall, Newsticker, Bilder/Videos, stay_on_first) |
|
||||
@@ -194,6 +217,7 @@ Server läuft auf `http://localhost:5005` (gunicorn mit 4 Workern, non-root User
|
||||
| `POST /admin/<site>/add-url/<screen>` | URL zur Playlist hinzufügen |
|
||||
| `POST /admin/<site>/delete/<screen>/<filename>` | Datei löschen |
|
||||
| `POST /admin/<site>/playlist/<screen>` | Playlist-Reihenfolge speichern (JSON) |
|
||||
| `POST /admin/<site>/update-playlist-item/<screen>` | Einzelnen Playlist-Eintrag ändern: URL, Zoom, Passthrough, Zeitsteuerung (JSON) |
|
||||
| `POST /admin/<site>/delete-screen/<screen>` | Screen + Medien löschen |
|
||||
| `GET /admin/<site>/add-screen?name=<name>` | Neuen Screen anlegen |
|
||||
| `GET /add-site?name=<name>` | Neuen Standort anlegen |
|
||||
@@ -202,7 +226,7 @@ Server läuft auf `http://localhost:5005` (gunicorn mit 4 Workern, non-root User
|
||||
| `POST /admin/<site>/delete-background` | Hintergrundbild zurücksetzen |
|
||||
| `GET /player/<site>/<screen>` | Player-Ansicht |
|
||||
| `GET /playlist/<site>/<screen>/hash` | Playlist-Checksumme (für Auto-Reload) + Heartbeat (Online-Status) |
|
||||
| `GET /willkommen?site=<site>` | Willkommensseite-Formular (GET + POST) |
|
||||
| `GET /willkommen?site=<site>` | Willkommensseite-Formular (GET + POST) – **ohne Login** |
|
||||
| `POST /api/customer` | API-Endpunkt für Willkommensseite (JSON) |
|
||||
| `GET /change-password` | Passwort ändern (First-Login / nach Admin-Reset) |
|
||||
| `GET /admin/users` | User-Liste (Admin) |
|
||||
@@ -218,7 +242,10 @@ Server läuft auf `http://localhost:5005` (gunicorn mit 4 Workern, non-root User
|
||||
| `POST /admin/users/reset-password/<email>` | Passwort-Reset mit temporärem Passwort (Admin) |
|
||||
| `POST /admin/users/edit/<email>` | User bearbeiten speichern (Admin) |
|
||||
| `GET /media/<site>/<screen>/<file>` | Medien-Datei ausliefern |
|
||||
| `GET /media/priority/<file>` | Priority-Medien (global) |
|
||||
| `GET /media/<site>/priority/<file>` | Priority-Medien des Standorts |
|
||||
| `GET /media/<site>/logos/<file>` | Manuell hochgeladenes Kundenlogo |
|
||||
| `GET /admin/help` | Hilfeseite zur Bedienung |
|
||||
| `GET /customer` | Alias für `/willkommen` |
|
||||
| `GET /media/<site>/background/<filename>` | Hintergrundbild der Willkommensseite |
|
||||
|
||||
### Heartbeat / Online-Status
|
||||
@@ -260,6 +287,8 @@ http://localhost:5005/admin/dashboard
|
||||
- 5 Statistik-Cards (Standorte, Screens, User, Admins, Superuser) mit ApexCharts-Sparkline + Trendanzeige
|
||||
- Aktivitätsverlauf (History) als Liste mit Zeitstempel, Aktion und User
|
||||
- Alle Erstell-/Lösch-/Änderungsaktionen sowie Login/Logout werden protokolliert
|
||||
- **Seitennavigation**: 50 Einträge je Seite (`HISTORY_PER_PAGE`), Aufruf über `?page=<n>`; aufbewahrt werden 1000 Einträge (`HISTORY_MAX`)
|
||||
- Kennzahlen und Sparklines beziehen sich immer auf die **jüngsten 30 Aktionen** und ändern sich beim Blättern bewusst nicht – sonst würden die Trendpfeile springen, obwohl sich nichts geändert hat
|
||||
|
||||
### Player-URL
|
||||
|
||||
@@ -289,7 +318,19 @@ Beispiel:
|
||||
http://localhost:5005/willkommen?site=stuttgart
|
||||
```
|
||||
|
||||
Maximal 3 Kunden eingeben → Logos werden via OpenAI + Brandfetch gesucht → `welcome.html` wird in der Lobby-Playlist vorne eingefügt. Pro Standort kann ein eigenes Hintergrundbild hochgeladen werden (Fallback auf `static/wallpaper.png`).
|
||||
**Ohne Login erreichbar:** Die Seite ist bewusst nicht durch `@login_required` geschützt, damit auch Mitarbeitende ohne Benutzerkonto eine Begrüßung erstellen können. Ein angemeldeter User sieht dabei weiterhin nur seine freigegebenen Standorte; ohne Anmeldung stehen alle Standorte zur Auswahl, ein unbekannter Standort führt zu 403. Im Aktivitätsverlauf erscheinen solche Einträge als „nicht angemeldet". Die programmatische Variante `POST /api/customer` bleibt angemeldet.
|
||||
|
||||
Maximal 3 Kunden eingeben → Logos werden via OpenAI + Brandfetch gesucht → `welcome.html` wird in den Playlists aller Zielscreens vorne eingefügt. Pro Standort kann ein eigenes Hintergrundbild hochgeladen werden (Fallback auf `static/wallpaper.png`).
|
||||
|
||||
**Zielscreens:** Jeder Screen mit `welcome_enabled` (Schalter „Willkommensseite hier anzeigen" im Tab Einstellungen). Besitzt kein Screen des Standorts den Schlüssel, greift das alte Verhalten und die Seite geht nur in `lobby`.
|
||||
|
||||
**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>/logo_<slot>.png` abgelegt und über `/media/<site>/logos/<datei>` ausgeliefert. **Bewusst als Datei im Standort-Verzeichnis, nicht in einem Unterordner:** `admin()` behandelt jeden Unterordner von `media/<site>/` als Screen und schreibt ihn dauerhaft in die Konfiguration – ein Ordner `logos` würde also als Phantom-Screen in der Oberfläche auftauchen. Das Hintergrundbild liegt aus demselben Grund als `media/<site>/background.*`. Der Ordnername `logos` ist zusätzlich reserviert und wird bei der Screen-Erkennung übersprungen.
|
||||
|
||||
**Freistellen und Zuschneiden:** Beim Upload wird der Hintergrund automatisch entfernt, damit das Logo nicht in einem weissen Kasten auf dem Hintergrundbild steht. Anschliessend werden die durchsichtigen Ränder weggeschnitten (`trim_transparent_border`). Ohne diesen Schnitt erscheint ein hochgeladenes Logo kleiner als ein automatisch gefundenes: die Willkommensseite skaliert mit `object-fit: contain` auf eine feste Höhe von 120px, und ein breiter Rand zählt dabei mit – Brandfetch-Logos sind bereits eng beschnitten. Verfahren: Flood Fill von den Bildrändern her (bewusst kein globaler Farbvergleich – so bleibt Weiss *innerhalb* des Logos erhalten), weiche Kante gegen Treppeneffekte, Speicherung immer als PNG. Bereits transparente Bilder werden unverändert übernommen; ist der Bildrand nicht einfarbig, bleibt das Bild unangetastet und die Oberfläche weist darauf hin. 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.
|
||||
|
||||
**Ansprechpartner:** Pro Kunde können Kontaktdaten eingegeben werden (Markdown-Format).
|
||||
- **Fett**: `**Text**`
|
||||
@@ -390,10 +431,6 @@ OPENAI_API_KEY=sk-...
|
||||
"background_url": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"priority": {
|
||||
"enabled": true,
|
||||
"playlist": [...]
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -404,7 +441,7 @@ OPENAI_API_KEY=sk-...
|
||||
|
||||
| Feld | Typ | Beschreibung |
|
||||
|------|-----|-------------|
|
||||
| `playlist` | Array | Playlist-Einträge (Strings oder Dicts mit `url`/`zoom`) |
|
||||
| `playlist` | Array | Playlist-Einträge (Strings oder Dicts, siehe [Playlist-Einträge](#playlist-eintraege)) |
|
||||
| `interval` | int | Anzeige-Intervall in Sekunden |
|
||||
| `show_images` | bool | Bilder anzeigen |
|
||||
| `show_videos` | bool | Videos anzeigen |
|
||||
@@ -416,12 +453,100 @@ OPENAI_API_KEY=sk-...
|
||||
| `custom_url_target` | string | `"overlay"` (iframe) oder `"redirect"` |
|
||||
| `custom_url_position` | string | Position des Buttons: `top-left`, `top-center`, `top-right`, `middle-left`, `middle-center`, `middle-right`, `bottom-left`, `bottom-center`, `bottom-right` |
|
||||
| `stay_on_first` | bool | Player bleibt auf erstem Element stehen (kein Refresh) |
|
||||
| `welcome_enabled` | bool | Willkommensseite wird auf diesem Screen vorne in die Playlist gesetzt |
|
||||
| `voice_agent_enabled` | bool | Voice-Agent-Button im Player anzeigen |
|
||||
| `voice_agent_show_image` | bool | Bild (dva.png) über dem Button anzeigen |
|
||||
| `voice_agent_label` | string | Button-Beschriftung (Default: "Digitaler Assistent") |
|
||||
| `voice_agent_target` | string | `"overlay"` (iframe) oder `"redirect"` |
|
||||
| `voice_agent_position` | string | Position des Buttons: `top-left`, `top-center`, `top-right`, `middle-left`, `middle-center`, `middle-right`, `bottom-left`, `bottom-center`, `bottom-right` |
|
||||
|
||||
## Priority-Playlist (pro Standort)
|
||||
|
||||
Liegt in `config.json` unter `sites.<site>.priority`, die Medien unter `media/<site>/priority/`:
|
||||
|
||||
```jsonc
|
||||
"sites": {
|
||||
"stuttgart": {
|
||||
"priority": {
|
||||
"enabled": true, // schaltet die Priority-Playlist dieses Standorts
|
||||
"playlist": [ ... ] // gleiches Eintragsformat wie eine Screen-Playlist
|
||||
},
|
||||
"screens": { ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Die Einträge laufen auf allen Screens **dieses** Standorts, eingestreut zwischen den normalen Playlist-Einträgen. Ein anderer Standort ist davon nicht betroffen.
|
||||
|
||||
**Vorschau in den Screen-Playlisten:** Ist Priority für den Standort aktiviert, erscheinen die Einträge zusätzlich über jeder Screen-Playlist – ausgegraut, mit rotem „Prio"-Badge und ohne Bedienelemente. Sie liegen bewusst in einer **eigenen `<ul>`**, damit das Speichern der Reihenfolge (`savePlaylist()`) sie nicht mit einsammelt und in die Screen-Playlist schreibt.
|
||||
|
||||
> Früher lag die Priority-Playlist global unter `config.priority` und wirkte auf *alle* Standorte. `get_priority_config()` übernimmt einen solchen Altbestand beim ersten Zugriff einmalig in den jeweiligen Standort.
|
||||
|
||||
Die Ordnernamen `priority` und `logos` sind unter `media/<site>/` **reserviert** und werden bei der Screen-Erkennung übersprungen – sonst würden sie als Screens in der Oberfläche auftauchen.
|
||||
|
||||
---
|
||||
|
||||
<a id="playlist-eintraege"></a>
|
||||
## Playlist-Einträge
|
||||
|
||||
Ein Eintrag ist entweder ein String (Dateiname oder URL, Altbestand) oder ein Dict:
|
||||
|
||||
```jsonc
|
||||
// URL-Eintrag
|
||||
{
|
||||
"url": "https://example.com",
|
||||
"zoom": 1.0, // Zoom-Faktor im Player
|
||||
"enabled": true, // Checkbox in der Playlist (nur der Schalter, nicht das Zeitfenster)
|
||||
"passthrough_params": false, // Query-Parameter der Player-URL durchreichen
|
||||
"schedule": { ... } // optional, siehe unten
|
||||
}
|
||||
|
||||
// Datei-Eintrag
|
||||
{
|
||||
"name": "bild.jpg",
|
||||
"enabled": true,
|
||||
"schedule": { ... } // optional
|
||||
}
|
||||
```
|
||||
|
||||
### Zeitsteuerung (`schedule`)
|
||||
|
||||
Optional pro Eintrag. Ohne `schedule` läuft der Eintrag immer (sofern `enabled`).
|
||||
|
||||
| Feld | Typ | Beschreibung |
|
||||
|------|-----|-------------|
|
||||
| `mode` | string | `"once"`, `"daily"` oder `"weekly"` |
|
||||
| `start` / `end` | string | Bei `once` voller Zeitstempel (`"2026-09-10T08:00"`), bei `daily`/`weekly` nur die Uhrzeit (`"08:00"`) |
|
||||
| `days` | Array | Nur bei `weekly`: Wochentage als Strings, `"0"` = Montag … `"6"` = Sonntag |
|
||||
| `valid_from` | string | Optional, nur bei `daily`/`weekly`: Datum (`"2026-09-10"`), ab dem das Fenster gilt – inklusiv |
|
||||
| `valid_until` | string | Optional, nur bei `daily`/`weekly`: Datum, bis zu dem das Fenster gilt – inklusiv |
|
||||
|
||||
```jsonc
|
||||
// Täglich 08:00–12:00, nur vom 10.09. bis 20.09.2026
|
||||
{ "mode": "daily", "start": "08:00", "end": "12:00",
|
||||
"valid_from": "2026-09-10", "valid_until": "2026-09-20" }
|
||||
|
||||
// Mo/Mi/Fr von 09:00 bis 17:00, unbefristet
|
||||
{ "mode": "weekly", "start": "09:00", "end": "17:00", "days": ["0", "2", "4"] }
|
||||
|
||||
// Einmalig, durchgehend über mehrere Tage
|
||||
{ "mode": "once", "start": "2026-09-10T08:00", "end": "2026-09-20T12:00" }
|
||||
```
|
||||
|
||||
**Verhaltensregeln:**
|
||||
|
||||
- Bei `daily` und `weekly` zählt **nur die Uhrzeit** von `start`/`end`. Ein Gültigkeitszeitraum wird über `valid_from`/`valid_until` gesetzt, nicht über den Datumsanteil.
|
||||
- Bei `daily` sind **alle Wochentage** gemeint; `days` wird in diesem Modus nicht ausgewertet.
|
||||
- Ist `start` > `end`, läuft das Fenster **über Mitternacht** (z. B. `22:00`–`02:00`). Ein solches Fenster gehört zum Wochentag bzw. Gültigkeitstag seines **Starts**: „Mi 22:00–02:00" läuft Mittwochabend bis Donnerstagfrüh.
|
||||
- Unlesbare oder unvollständige Zeitangaben blenden den Eintrag **nicht** aus – ein Konfigurationsfehler soll nicht dazu führen, dass stillschweigend nichts mehr läuft.
|
||||
- `_schedule_time()` liest neben `"08:00"` auch volle ISO-Zeitstempel, damit Einträge aus älteren Versionen unverändert weiterlaufen.
|
||||
- Der Aktiv-Zustand fliesst in `/playlist/<site>/<screen>/hash` ein, damit der Player beim Öffnen und Schliessen eines Zeitfensters von selbst neu lädt.
|
||||
- **Checkbox und Zeitfenster sind getrennt:** Die Checkbox in der Playlist-Zeile zeigt ausschliesslich den gespeicherten Schalter `enabled`, nicht den momentanen Zeitfenster-Zustand. Sie bleibt also gesetzt, auch wenn ein zeitgesteuerter Eintrag gerade nicht läuft. Ob er gerade läuft, sagt das „Zeit"-Badge: blau = läuft, grau = ausserhalb des Fensters (Tooltip nennt den Grund). Abgespielt wird nur, was **beides** erfüllt – Haken gesetzt *und* im Zeitfenster.
|
||||
|
||||
> Bis Version 6.2.4 zeigte die Checkbox den ausgewerteten Zustand. Wurde die Playlist ausserhalb des Zeitfensters gespeichert, schrieb die Oberfläche `enabled: false` in die Konfiguration und der Eintrag war dauerhaft abgeschaltet. Der Fehler ist behoben, wirkt aber nicht rückwirkend: bereits betroffene Einträge müssen einmal von Hand wieder aktiviert werden.
|
||||
|
||||
---
|
||||
|
||||
## Helper-Funktionen (`app.py`)
|
||||
|
||||
- `load_config()` / `save_config()` – JSON lesen/schreiben
|
||||
@@ -430,8 +555,21 @@ OPENAI_API_KEY=sk-...
|
||||
- `is_url(item)` – prüft ob Item eine URL ist
|
||||
- `normalize_url(item)` – normalisiert URL-Item zu `{"url", "zoom"}`
|
||||
- `playlist_item_name(item)` – Name aus String oder Dict extrahieren
|
||||
- `playlist_item_enabled(item)` – Enabled-Status prüfen
|
||||
- `load_priority_files()` – Priority-Playlist + Dateien laden
|
||||
- `is_item_active(item)` – prüft `enabled` **und** die Zeitsteuerung
|
||||
- `_schedule_time(value)` – Uhrzeit aus `"HH:MM"` oder vollem ISO-Zeitstempel lesen
|
||||
- `_window_start_date(now, start, end)` – Kalendertag, an dem das laufende Zeitfenster begann (`None` = ausserhalb)
|
||||
- `_in_validity_range(schedule, day)` – optionalen Gültigkeitszeitraum prüfen
|
||||
- `schedule_text(schedule)` – Jinja-Filter: Klartext einer Zeitsteuerung für Tooltips
|
||||
- `load_priority_files(site)` – Priority-Playlist eines Standorts + Dateien laden
|
||||
- `_load_history()` / `get_history(limit, offset)` / `get_history_count()` – Aktivitätsverlauf lesen, seitenweise ausschneiden, zählen
|
||||
- `get_priority_config(cfg, site)` – Priority-Konfiguration des Standorts (übernimmt einmalig einen globalen Altbestand)
|
||||
- `priority_dir(site)` – Pfad zu `media/<site>/priority/`
|
||||
- `welcome_target_screens(config, site)` – Zielscreens der Willkommensseite (Fallback `lobby`)
|
||||
- `save_uploaded_logo(file, site, slot, freistellen=True)` – hochgeladenes Logo freistellen und als PNG ablegen
|
||||
- `remove_logo_background(image)` – Hintergrund per Flood Fill entfernen, gibt `(bild, hinweis)` zurück
|
||||
- `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
|
||||
- `find_welcome_source(site, config)` – vorhandene `welcome.html` als Kopiervorlage finden
|
||||
- `prio_redirect(site)` – Redirect-Pfad zur Priority-Seite
|
||||
- `get_background_url(site)` – URL zum benutzerdefinierten Hintergrundbild oder `None`
|
||||
- `load_users()` / `save_users()` / `get_user()` – User aus `users.json`
|
||||
@@ -453,6 +591,7 @@ Wird über `localStorage("signage-theme")` persistiert. Umschalt-Button im Heade
|
||||
## Sicherheit
|
||||
|
||||
- Admin-Bereich per Flask-Login geschützt (E-Mail + Passwort)
|
||||
- **Ohne Login erreichbar** sind: Player und Medien (die Abspielgeräte melden sich nicht an) sowie das Formular der Willkommensseite. Letzteres legt Dateien an, schreibt in die Konfiguration und löst kostenpflichtige Logo-Abfragen aus – bei einer aus dem Internet erreichbaren Instanz empfiehlt sich eine Einschränkung auf das interne Netz im Reverse Proxy
|
||||
- Optionale MFA (TOTP) per User aktivierbar (Google Authenticator, Authy)
|
||||
- 8 Recovery-Codes pro User, einmalig bei Aktivierung angezeigt
|
||||
- Passwörter gehasht (scrypt) in `users.json`
|
||||
|
||||
431
app.py
431
app.py
@@ -10,6 +10,7 @@ zugeschaltet werden, die auf allen Playern abwechselnd eingeblendet wird.
|
||||
|
||||
import os
|
||||
import json
|
||||
import copy
|
||||
import hashlib
|
||||
import re
|
||||
import time
|
||||
@@ -49,14 +50,37 @@ MEDIA_DIR = os.path.join(BASE_DIR, "media") # Medien-Dateien je Standort/S
|
||||
CONFIG_FILE = os.path.join(BASE_DIR, "config.json") # Persistente Konfiguration
|
||||
USERS_FILE = os.path.join(BASE_DIR, "users.json") # User-Datenbank
|
||||
HISTORY_FILE = os.path.join(BASE_DIR, "history.json") # Aktivitätsverlauf
|
||||
HISTORY_MAX = 1000 # so viele Einträge werden aufbewahrt
|
||||
HISTORY_PER_PAGE = 50 # Einträge je Seite im Dashboard-Verlauf
|
||||
|
||||
APP_VERSION = "6.1.0"
|
||||
APP_VERSION = "6.2.12"
|
||||
UPLOAD_EXTENSIONS = {".jpg", ".jpeg", ".png", ".mp4"}
|
||||
|
||||
app = Flask(__name__)
|
||||
app.secret_key = "CHANGE_THIS_SECRET!!!"
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1)
|
||||
|
||||
def remember_site(site):
|
||||
"""Merkt den zuletzt besuchten Standort in der Session."""
|
||||
if site:
|
||||
session["last_site"] = site
|
||||
|
||||
|
||||
def current_site_for(cfg, user=None):
|
||||
"""Standort für Seiten ohne eigenen Standortbezug (Dashboard, Hilfe,
|
||||
User-Verwaltung, MFA).
|
||||
|
||||
Nimmt den zuletzt besuchten Standort, sonst den ersten *zugänglichen*.
|
||||
Ohne das springt die Standortanzeige im Header auf den global ersten
|
||||
Standort - und der "Übersicht"-Link führt anschliessend dorthin statt
|
||||
zurück zu dem Standort, von dem man kam."""
|
||||
sites = get_accessible_sites(cfg, user) if (user is not None and user.is_authenticated) else get_site_list(cfg)
|
||||
letzter = session.get("last_site")
|
||||
if letzter in sites:
|
||||
return letzter
|
||||
return sites[0] if sites else ""
|
||||
|
||||
|
||||
@app.context_processor
|
||||
def inject_globals():
|
||||
"""Stellt globale Template-Variablen für Header/Footer bereit."""
|
||||
@@ -66,7 +90,7 @@ def inject_globals():
|
||||
from flask_login import current_user
|
||||
if current_user.is_authenticated:
|
||||
site_list = get_accessible_sites(cfg, current_user)
|
||||
current_site_val = get_site_list(cfg)[0] if get_site_list(cfg) else ""
|
||||
current_site_val = current_site_for(cfg, current_user)
|
||||
else:
|
||||
site_list = get_site_list(cfg)
|
||||
current_site_val = site_list[0] if site_list else ""
|
||||
@@ -161,24 +185,33 @@ def add_history_entry(action, detail, user_email):
|
||||
except (json.JSONDecodeError, OSError):
|
||||
history = []
|
||||
history.insert(0, entry) # Neueste zuerst
|
||||
# Maximal 200 Einträge behalten
|
||||
history = history[:200]
|
||||
# Maximal HISTORY_MAX Einträge behalten
|
||||
history = history[:HISTORY_MAX]
|
||||
with open(HISTORY_FILE, "w") as f:
|
||||
json.dump(history, f, indent=2)
|
||||
|
||||
|
||||
def get_history(limit=50):
|
||||
"""Gibt die letzten history-Einträge zurück."""
|
||||
def _load_history():
|
||||
"""Liest den kompletten Verlauf; bei defekter Datei eine leere Liste."""
|
||||
if not os.path.exists(HISTORY_FILE):
|
||||
return []
|
||||
try:
|
||||
with open(HISTORY_FILE) as f:
|
||||
history = json.load(f)
|
||||
return history[:limit]
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return []
|
||||
|
||||
|
||||
def get_history(limit=50, offset=0):
|
||||
"""Gibt einen Ausschnitt des Verlaufs zurück (neueste zuerst)."""
|
||||
return _load_history()[offset:offset + limit]
|
||||
|
||||
|
||||
def get_history_count():
|
||||
"""Anzahl aller gespeicherten Verlaufseintraege (fuer die Seitennavigation)."""
|
||||
return len(_load_history())
|
||||
|
||||
|
||||
def get_screen_config(cfg, site, screen):
|
||||
"""Holt die Konfiguration für einen bestimmten Screen.
|
||||
Erzeugt automatisch fehlende Dict-Ebenen (sites → site → screens → screen)."""
|
||||
@@ -380,16 +413,38 @@ def is_item_active(item):
|
||||
# -------------------------------------------------
|
||||
# Priority-Playlist-Helfer
|
||||
# -------------------------------------------------
|
||||
def load_priority_files():
|
||||
"""Lädt die Priority-Playlist aus config.json plus die dazugehörigen Dateien
|
||||
aus media/priority/. Gibt eine Liste von Dicts mit name/type/size/enabled zurück.
|
||||
PRIORITY_DIRNAME = "priority"
|
||||
|
||||
|
||||
def priority_dir(site):
|
||||
"""Verzeichnis der Priority-Medien eines Standorts: media/<site>/priority/."""
|
||||
return os.path.join(MEDIA_DIR, site, PRIORITY_DIRNAME)
|
||||
|
||||
|
||||
def get_priority_config(cfg, site):
|
||||
"""Priority-Konfiguration eines Standorts (erzeugt fehlende Ebenen).
|
||||
|
||||
Die Priority-Playlist gilt ausschliesslich fuer den eigenen Standort.
|
||||
Aeltere Konfigurationen hatten sie global unter cfg["priority"]; ist beim
|
||||
Standort noch nichts hinterlegt, wird der Altbestand einmalig uebernommen."""
|
||||
site_cfg = cfg.setdefault("sites", {}).setdefault(site, {})
|
||||
if "priority" not in site_cfg:
|
||||
alt = cfg.get("priority")
|
||||
site_cfg["priority"] = copy.deepcopy(alt) if isinstance(alt, dict) else {}
|
||||
return site_cfg["priority"]
|
||||
|
||||
|
||||
def load_priority_files(site):
|
||||
"""Lädt die Priority-Playlist eines Standorts aus config.json plus die
|
||||
dazugehörigen Dateien aus media/<site>/priority/.
|
||||
Gibt eine Liste von Dicts mit name/type/size/enabled zurück.
|
||||
Neue Dateien im Ordner, die noch nicht in der Playlist sind, werden angehängt."""
|
||||
cfg = load_config()
|
||||
prio_dir = os.path.join(MEDIA_DIR, "priority")
|
||||
prio_dir = priority_dir(site)
|
||||
os.makedirs(prio_dir, exist_ok=True)
|
||||
|
||||
priority_files = []
|
||||
prio_playlist = cfg.get("priority", {}).get("playlist", [])
|
||||
prio_playlist = get_priority_config(cfg, site).get("playlist", [])
|
||||
|
||||
# 1. Einträge aus der config.json-Playlist
|
||||
for item in prio_playlist:
|
||||
@@ -402,7 +457,8 @@ def load_priority_files():
|
||||
"type": "url",
|
||||
"size": "URL",
|
||||
"zoom": url_data.get("zoom", 1.0),
|
||||
"enabled": is_item_active(item),
|
||||
"enabled": item.get("enabled", True) if isinstance(item, dict) else True,
|
||||
"active": is_item_active(item),
|
||||
"passthrough": item.get("passthrough_params", False) if isinstance(item, dict) else False,
|
||||
"schedule": item.get("schedule") if isinstance(item, dict) else None
|
||||
})
|
||||
@@ -425,7 +481,8 @@ def load_priority_files():
|
||||
"name": item_name,
|
||||
"type": ftype,
|
||||
"size": size,
|
||||
"enabled": is_item_active(item),
|
||||
"enabled": item.get("enabled", True) if isinstance(item, dict) else True,
|
||||
"active": is_item_active(item),
|
||||
"schedule": item.get("schedule") if isinstance(item, dict) else None
|
||||
})
|
||||
|
||||
@@ -493,6 +550,16 @@ def media_background(site, filename):
|
||||
return send_from_directory(path, filename)
|
||||
|
||||
|
||||
@app.route("/media/<site>/logos/<path:filename>")
|
||||
def media_logo(site, filename):
|
||||
"""Liefert ein manuell hochgeladenes Kundenlogo aus.
|
||||
Ohne Login erreichbar wie die übrigen Medien - der Player ist nicht angemeldet.
|
||||
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)
|
||||
|
||||
|
||||
# -------------------------------------------------
|
||||
# User-Verwaltung (Flask-Login mit users.json)
|
||||
# -------------------------------------------------
|
||||
@@ -677,29 +744,29 @@ def login():
|
||||
trust_token = request.cookies.get("mfa_trust")
|
||||
if trust_token:
|
||||
now = datetime.now()
|
||||
# Abgelaufene Einträge bereinigen
|
||||
active = [d for d in user.trusted_devices
|
||||
if d.get("token") != trust_token or
|
||||
datetime.fromisoformat(d["expires"]) > now]
|
||||
changed = len(active) != len(user.trusted_devices)
|
||||
treffer = None
|
||||
aktiv = []
|
||||
for d in user.trusted_devices:
|
||||
if d.get("token") == trust_token:
|
||||
try:
|
||||
expires = datetime.fromisoformat(d["expires"])
|
||||
if expires > now:
|
||||
except (ValueError, KeyError, TypeError):
|
||||
continue # kaputter Eintrag fliegt raus
|
||||
if expires <= now:
|
||||
continue # abgelaufener Eintrag fliegt raus
|
||||
aktiv.append(d)
|
||||
if d.get("token") == trust_token:
|
||||
treffer = d
|
||||
if len(aktiv) != len(user.trusted_devices):
|
||||
users_data = load_users()
|
||||
users_data.setdefault(email, {})["trusted_devices"] = aktiv
|
||||
save_users(users_data)
|
||||
if treffer:
|
||||
# Trusted Device gültig → MFA überspringen
|
||||
login_user(user)
|
||||
add_history_entry("user_login", f"User '{email}' angemeldet (Trusted Device)", email)
|
||||
if user.must_change_password:
|
||||
return redirect("/change-password")
|
||||
return redirect("/admin")
|
||||
else:
|
||||
pass
|
||||
except (ValueError, KeyError):
|
||||
pass
|
||||
if changed:
|
||||
users_data[email]["trusted_devices"] = active
|
||||
save_users(users_data)
|
||||
session["mfa_user"] = email
|
||||
session["mfa_password_ok"] = True
|
||||
return redirect("/mfa/verify")
|
||||
@@ -774,7 +841,7 @@ def mfa_verify():
|
||||
resp.set_cookie("mfa_trust", token, max_age=days * 86400,
|
||||
path="/",
|
||||
httponly=True, samesite="Lax",
|
||||
secure=False)
|
||||
secure=request.is_secure)
|
||||
return resp
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
@@ -830,11 +897,11 @@ def mfa_setup():
|
||||
save_users(users_data)
|
||||
add_history_entry("mfa_enabled", f"User '{email}' hat MFA aktiviert", email)
|
||||
return render_template("mfa_recovery.html", codes=recovery, email=email,
|
||||
site_list=site_list, current_site=site_list[0] if site_list else "",
|
||||
site_list=site_list, current_site=current_site_for(cfg, current_user),
|
||||
brand_subtitle="MFA", site_dropdown_url_prefix="/admin/")
|
||||
return render_template("mfa_setup.html", error="Code ungültig. Bitte versuche es erneut.",
|
||||
email=email, secret=secret, qr_data=None,
|
||||
site_list=site_list, current_site=site_list[0] if site_list else "",
|
||||
site_list=site_list, current_site=current_site_for(cfg, current_user),
|
||||
brand_subtitle="MFA", site_dropdown_url_prefix="/admin/")
|
||||
|
||||
uri = totp.provisioning_uri(name=email, issuer_name="CANCOM Signage")
|
||||
@@ -846,7 +913,7 @@ def mfa_setup():
|
||||
return render_template("mfa_setup.html", error=None if not error_param else "Passwort ist falsch",
|
||||
email=email, secret=secret, qr_data=qr_b64,
|
||||
recovered=recovered, disabled=disabled,
|
||||
site_list=site_list, current_site=site_list[0] if site_list else "",
|
||||
site_list=site_list, current_site=current_site_for(cfg, current_user),
|
||||
brand_subtitle="MFA", site_dropdown_url_prefix="/admin/")
|
||||
|
||||
|
||||
@@ -886,31 +953,42 @@ def mfa_disable():
|
||||
# -------------------------------------------------
|
||||
@app.route("/willkommen", methods=["GET", "POST"])
|
||||
@app.route("/customer", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def add_customer():
|
||||
"""
|
||||
Formular für die Willkommensseite:
|
||||
- Bis zu 3 Kundennamen eingeben
|
||||
- Logos per OpenAI → Brandfetch suchen
|
||||
- welcome.html wird generiert und in der Lobby-Playlist vorne platziert
|
||||
- welcome.html wird generiert und in den Playlists aller Zielscreens
|
||||
vorne platziert (Screens mit "welcome_enabled")
|
||||
"""
|
||||
error = None
|
||||
success = None
|
||||
logo_urls = [None, None, None]
|
||||
customer_names = ["", "", ""]
|
||||
contacts = ["", "", ""]
|
||||
valid_until = (request.form.get("valid_until") or "").strip()
|
||||
logo_hinweise = []
|
||||
site = request.args.get("site") or request.form.get("site") or ""
|
||||
|
||||
# Fallback auf ersten für den User zugänglichen Standort
|
||||
if not site:
|
||||
# Diese Seite ist bewusst ohne Login erreichbar: Am Empfang soll eine
|
||||
# Begruessung auch ohne Benutzerkonto erstellt werden koennen. Ein
|
||||
# angemeldeter User sieht weiterhin nur seine freigegebenen Standorte.
|
||||
config = load_config()
|
||||
sites = get_accessible_sites(config, current_user)
|
||||
site = sites[0] if sites else ""
|
||||
if current_user.is_authenticated:
|
||||
erlaubte_sites = get_accessible_sites(config, current_user)
|
||||
else:
|
||||
erlaubte_sites = get_site_list(config)
|
||||
|
||||
# Prüfen, ob der User Zugriff auf den Standort hat
|
||||
if site and not current_user.can_access_site(site):
|
||||
# Fallback auf den ersten zugaenglichen Standort
|
||||
if not site:
|
||||
site = erlaubte_sites[0] if erlaubte_sites else ""
|
||||
|
||||
# Zugriff auf den Standort pruefen
|
||||
if site and site not in erlaubte_sites:
|
||||
abort(403)
|
||||
|
||||
remember_site(site)
|
||||
|
||||
if request.method == "POST":
|
||||
customer_names = [
|
||||
request.form.get("customer_name_1", "").strip(),
|
||||
@@ -932,47 +1010,94 @@ def add_customer():
|
||||
error = "Kein Standort ausgewählt"
|
||||
else:
|
||||
try:
|
||||
found_logos = []
|
||||
for name in filled_names:
|
||||
logo = generate_welcome_page.search_customer_logo(name)
|
||||
found_logos.append(logo)
|
||||
# Logo je Kunde bestimmen: ein hochgeladenes Logo hat Vorrang vor
|
||||
# der automatischen Suche - der Upload ist eine bewusste Entscheidung.
|
||||
# Zuletzt verwendete Logos, um einen früheren Upload nicht bei jedem
|
||||
# erneuten Generieren zu verlieren
|
||||
frueher = load_config().get("sites", {}).get(site, {}).get("welcome_data", {})
|
||||
frueher_namen = frueher.get("names", [])
|
||||
frueher_urls = frueher.get("logo_urls", [])
|
||||
logo_prefix = f"/media/{site}/{generate_welcome_page.LOGO_URL_PREFIX}/"
|
||||
|
||||
valid = [(n, l) for n, l in zip(filled_names, found_logos) if l]
|
||||
found_logos = [None, None, None]
|
||||
for i, name in enumerate(customer_names):
|
||||
if not name:
|
||||
continue
|
||||
datei = request.files.get(f"logo_file_{i + 1}")
|
||||
# Eindeutig protokollieren, ob ueberhaupt eine Datei ankam -
|
||||
# ein fehlendes enctype im Formular (etwa aus dem Browser-Cache)
|
||||
# sieht sonst genauso aus wie "kein Logo hochgeladen".
|
||||
print(f"🔍 APP.PY - Slot {i + 1} ({name}): Upload = "
|
||||
f"{datei.filename if datei and datei.filename else 'keiner'}")
|
||||
if datei and datei.filename:
|
||||
url, hinweis = generate_welcome_page.save_uploaded_logo(
|
||||
datei, site, i + 1,
|
||||
freistellen=(f"logo_keep_bg_{i + 1}" not in request.form))
|
||||
found_logos[i] = url
|
||||
if hinweis:
|
||||
logo_hinweise.append(f"{name}: {hinweis}")
|
||||
elif not url:
|
||||
logo_hinweise.append(f"{name}: Logo konnte nicht übernommen werden")
|
||||
continue
|
||||
|
||||
# Kein neuer Upload: ein zuvor hochgeladenes Logo weiterverwenden,
|
||||
# solange der Kundenname in diesem Feld derselbe geblieben ist
|
||||
alt_url = frueher_urls[i] if i < len(frueher_urls) else None
|
||||
alt_name = frueher_namen[i] if i < len(frueher_namen) else None
|
||||
if alt_url and alt_name == name and str(alt_url).startswith(logo_prefix):
|
||||
found_logos[i] = alt_url
|
||||
continue
|
||||
|
||||
found_logos[i] = generate_welcome_page.search_customer_logo(name)
|
||||
|
||||
logo_urls = list(found_logos)
|
||||
valid = [(customer_names[i], found_logos[i], contacts[i])
|
||||
for i in range(len(customer_names))
|
||||
if customer_names[i] and found_logos[i]]
|
||||
|
||||
if not valid:
|
||||
error = "Kein Logo konnte gefunden werden"
|
||||
error = "Kein Logo gefunden – bitte für die betroffenen Kunden ein Logo hochladen"
|
||||
else:
|
||||
valid_names = [n for n, l in valid]
|
||||
valid_urls = [l for n, l in valid]
|
||||
valid_contacts = []
|
||||
for name in valid_names:
|
||||
for i, cn in enumerate(customer_names):
|
||||
if cn == name:
|
||||
valid_contacts.append(contacts[i] if i < len(contacts) else "")
|
||||
break
|
||||
else:
|
||||
valid_contacts.append("")
|
||||
for i, name in enumerate(customer_names):
|
||||
for j, (vn, vu) in enumerate(zip(valid_names, valid_urls)):
|
||||
if vn == name:
|
||||
logo_urls[i] = vu
|
||||
break
|
||||
valid_names = [n for n, u, k in valid]
|
||||
valid_urls = [u for n, u, k in valid]
|
||||
valid_contacts = [k for n, u, k in valid]
|
||||
|
||||
bg_url = get_background_url(site)
|
||||
html_filename = generate_welcome_page.save_welcome_page(valid_names, valid_urls, contacts=valid_contacts, site=site, background_url=bg_url)
|
||||
targets = generate_welcome_page.welcome_target_screens(load_config(), site)
|
||||
|
||||
if not targets:
|
||||
error = ("Kein Screen für die Willkommensseite ausgewählt – "
|
||||
"im Tab 'Einstellungen' eines Screens aktivieren")
|
||||
html_filename = None
|
||||
else:
|
||||
html_filename = generate_welcome_page.save_welcome_page(
|
||||
valid_names, valid_urls, contacts=valid_contacts,
|
||||
site=site, background_url=bg_url, screens=targets)
|
||||
|
||||
if html_filename:
|
||||
if generate_welcome_page.add_customer_to_lobby_playlist(html_filename, site=site):
|
||||
if generate_welcome_page.add_customer_to_playlists(
|
||||
html_filename, site=site, screens=targets, valid_until=valid_until):
|
||||
# Letzte Kundendaten für Background-Update speichern
|
||||
config = load_config()
|
||||
site_cfg = config.setdefault("sites", {}).setdefault(site, {})
|
||||
site_cfg["welcome_data"] = {"names": valid_names, "logo_urls": valid_urls, "contacts": valid_contacts}
|
||||
# Feldweise speichern (Slot 1-3), nicht nur die erfolgreichen -
|
||||
# sonst rutscht Kunde 2 beim naechsten Aufruf in Feld 1 und die
|
||||
# Zuordnung von Logo und Ansprechpartner verschiebt sich mit.
|
||||
site_cfg["welcome_data"] = {
|
||||
"names": list(customer_names),
|
||||
"logo_urls": list(found_logos),
|
||||
"contacts": list(contacts)
|
||||
}
|
||||
save_config(config)
|
||||
add_history_entry("customer_added", f"Kunden ({', '.join(valid_names)}) zur Willkommensseite an Standort '{site}' hinzugefügt", current_user.email)
|
||||
success = f"✅ Willkommensseite mit {len(valid_names)} Kunden erstellt!"
|
||||
add_history_entry("customer_added", f"Kunden ({', '.join(valid_names)}) zur Willkommensseite an Standort '{site}' hinzugefügt (Screens: {', '.join(targets)})", current_user.email if current_user.is_authenticated else "nicht angemeldet")
|
||||
success = (f"✅ Willkommensseite mit {len(valid_names)} Kunden erstellt "
|
||||
f"– angezeigt auf: {', '.join(targets)}")
|
||||
if valid_until:
|
||||
success += f" (bis einschliesslich {valid_until})"
|
||||
else:
|
||||
error = "Fehler beim Hinzufügen zur Playliste"
|
||||
else:
|
||||
elif targets:
|
||||
# Ohne Zielscreens steht die Meldung dazu bereits in error
|
||||
error = "Fehler beim Speichern der Willkommensseite"
|
||||
except ValueError as e:
|
||||
error = f"Konfigurationsfehler: {str(e)}"
|
||||
@@ -1002,6 +1127,8 @@ def add_customer():
|
||||
logo_urls=logo_urls,
|
||||
customer_names=customer_names,
|
||||
contacts=contacts,
|
||||
welcome_screens=generate_welcome_page.welcome_target_screens(config, site) if site else [],
|
||||
logo_hinweise=logo_hinweise,
|
||||
site=site,
|
||||
site_list=site_list,
|
||||
current_site=site,
|
||||
@@ -1047,18 +1174,27 @@ def api_add_customer():
|
||||
|
||||
contact = data.get("contact", "").strip()
|
||||
|
||||
html_filename = generate_welcome_page.save_welcome_page([customer_name], [logo_url], contacts=[contact], site=site)
|
||||
valid_until = (data.get("valid_until") or "").strip()
|
||||
targets = generate_welcome_page.welcome_target_screens(load_config(), site)
|
||||
|
||||
if not targets:
|
||||
return jsonify({"error": "No welcome screen configured for this site"}), 400
|
||||
|
||||
html_filename = generate_welcome_page.save_welcome_page(
|
||||
[customer_name], [logo_url], contacts=[contact], site=site, screens=targets)
|
||||
|
||||
if not html_filename:
|
||||
return jsonify({"error": "Failed to create welcome page"}), 500
|
||||
|
||||
if generate_welcome_page.add_customer_to_lobby_playlist(html_filename, site=site):
|
||||
if generate_welcome_page.add_customer_to_playlists(
|
||||
html_filename, site=site, screens=targets, valid_until=valid_until):
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"message": f"Customer '{customer_name}' added successfully",
|
||||
"welcome_page": html_filename,
|
||||
"logo_url": logo_url,
|
||||
"site": site
|
||||
"site": site,
|
||||
"screens": targets
|
||||
}), 201
|
||||
else:
|
||||
return jsonify({"error": "Failed to add to playlist"}), 500
|
||||
@@ -1072,18 +1208,41 @@ def api_add_customer():
|
||||
# -------------------------------------------------
|
||||
# Medien ausliefern (statische Dateien)
|
||||
# -------------------------------------------------
|
||||
@app.route("/media/priority/<path:filename>")
|
||||
def media_priority(filename):
|
||||
"""Liefert globale Priority-Medien aus media/priority/ aus."""
|
||||
path = os.path.join(MEDIA_DIR, "priority")
|
||||
return send_from_directory(path, filename)
|
||||
# Traegt die URL ein ?v=<mtime>, darf der Player die Datei ein Jahr lang ohne
|
||||
# Rueckfrage aus dem Cache nehmen: eine geaenderte Datei bekommt eine neue URL.
|
||||
# Ohne ?v bleibt es beim Flask-Standard (no-cache + ETag), sonst saehe ein
|
||||
# unversionierter Aufruf nach dem Ersetzen einer Datei weiter die alte Fassung.
|
||||
MEDIA_CACHE_MAX_AGE = 365 * 24 * 3600
|
||||
|
||||
|
||||
def media_version(path):
|
||||
"""Liefert die mtime einer Datei als Versionskennung fuer ?v=, 0 wenn sie fehlt."""
|
||||
try:
|
||||
return int(os.path.getmtime(path))
|
||||
except OSError:
|
||||
return 0
|
||||
|
||||
|
||||
def send_media(directory, filename):
|
||||
"""send_from_directory mit langem Cache, sofern die URL versioniert ist."""
|
||||
if not request.args.get("v"):
|
||||
return send_from_directory(directory, filename)
|
||||
resp = send_from_directory(directory, filename, max_age=MEDIA_CACHE_MAX_AGE)
|
||||
resp.cache_control.immutable = True
|
||||
return resp
|
||||
|
||||
|
||||
@app.route("/media/<site>/priority/<path:filename>")
|
||||
def media_priority(site, filename):
|
||||
"""Liefert die Priority-Medien eines Standorts aus media/<site>/priority/ aus."""
|
||||
return send_media(priority_dir(site), filename)
|
||||
|
||||
|
||||
@app.route("/media/<site>/<screen>/<path:filename>")
|
||||
def media(site, screen, filename):
|
||||
"""Liefert Medien-Dateien für einen bestimmten Screen aus."""
|
||||
path = os.path.join(MEDIA_DIR, site, screen)
|
||||
return send_from_directory(path, filename)
|
||||
return send_media(path, filename)
|
||||
|
||||
|
||||
# -------------------------------------------------
|
||||
@@ -1139,11 +1298,13 @@ def player(site, screen):
|
||||
if is_url(item):
|
||||
url_data = normalize_url(item)
|
||||
if url_data:
|
||||
normal_files.append({"kind": "url", "url": url_data["url"], "zoom": url_data.get("zoom", 1.0), "enabled": is_item_active(item), "passthrough": item.get("passthrough_params", False) if isinstance(item, dict) else False, "schedule": item.get("schedule") if isinstance(item, dict) else None})
|
||||
normal_files.append({"kind": "url", "url": url_data["url"], "zoom": url_data.get("zoom", 1.0), "enabled": item.get("enabled", True) if isinstance(item, dict) else True,
|
||||
"active": is_item_active(item), "passthrough": item.get("passthrough_params", False) if isinstance(item, dict) else False, "schedule": item.get("schedule") if isinstance(item, dict) else None})
|
||||
continue
|
||||
|
||||
if item_name and os.path.exists(os.path.join(folder, item_name)) and allowed_file(item_name):
|
||||
normal_files.append({"kind": "file", "name": item_name, "enabled": True})
|
||||
normal_files.append({"kind": "file", "name": item_name, "enabled": True,
|
||||
"v": media_version(os.path.join(folder, item_name))})
|
||||
|
||||
existing_file_names = {
|
||||
item["name"] for item in normal_files if item["kind"] == "file"
|
||||
@@ -1155,10 +1316,11 @@ def player(site, screen):
|
||||
if f in existing_file_names or f.startswith("._"):
|
||||
continue
|
||||
if allowed_file(f):
|
||||
normal_files.append({"kind": "file", "name": f})
|
||||
normal_files.append({"kind": "file", "name": f,
|
||||
"v": media_version(os.path.join(folder, f))})
|
||||
|
||||
# ─── Priority-Playlist (global) ───
|
||||
priority_cfg = config.get("priority", {})
|
||||
priority_cfg = get_priority_config(config, site)
|
||||
|
||||
if priority_cfg.get("enabled", False):
|
||||
prio_files = []
|
||||
@@ -1169,12 +1331,14 @@ def player(site, screen):
|
||||
if is_url(item):
|
||||
url_data = normalize_url(item)
|
||||
if url_data:
|
||||
prio_files.append({"kind": "url", "url": url_data["url"], "zoom": url_data.get("zoom", 1.0), "enabled": is_item_active(item), "passthrough": item.get("passthrough_params", False) if isinstance(item, dict) else False, "schedule": item.get("schedule") if isinstance(item, dict) else None})
|
||||
prio_files.append({"kind": "url", "url": url_data["url"], "zoom": url_data.get("zoom", 1.0), "enabled": item.get("enabled", True) if isinstance(item, dict) else True,
|
||||
"active": is_item_active(item), "passthrough": item.get("passthrough_params", False) if isinstance(item, dict) else False, "schedule": item.get("schedule") if isinstance(item, dict) else None})
|
||||
continue
|
||||
|
||||
file_path = os.path.join(MEDIA_DIR, "priority", item_name)
|
||||
file_path = os.path.join(priority_dir(site), item_name)
|
||||
if os.path.exists(file_path):
|
||||
prio_files.append({"kind": "file", "name": item_name, "enabled": True})
|
||||
prio_files.append({"kind": "file", "name": item_name, "enabled": True,
|
||||
"v": media_version(file_path)})
|
||||
else:
|
||||
prio_files = []
|
||||
|
||||
@@ -1182,8 +1346,10 @@ def player(site, screen):
|
||||
"player.html",
|
||||
site=site,
|
||||
screen=screen,
|
||||
normal_files=[item for item in normal_files if item.get("enabled", True)],
|
||||
prio_files=[item for item in prio_files if item.get("enabled", True)],
|
||||
normal_files=[item for item in normal_files
|
||||
if item.get("enabled", True) and item.get("active", True)],
|
||||
prio_files=[item for item in prio_files
|
||||
if item.get("enabled", True) and item.get("active", True)],
|
||||
interval=screen_cfg.get("interval", 10),
|
||||
newsticker_text=screen_cfg.get("newsticker_text", ""),
|
||||
newsticker_enabled=screen_cfg.get("newsticker_enabled", False),
|
||||
@@ -1225,7 +1391,7 @@ def playlist_hash(site, screen):
|
||||
screen_playlist = (
|
||||
config.get("sites", {}).get(site, {}).get("screens", {}).get(screen, {}).get("playlist", [])
|
||||
)
|
||||
prio_cfg = config.get("priority", {})
|
||||
prio_cfg = get_priority_config(config, site)
|
||||
prio_playlist = prio_cfg.get("playlist", []) if prio_cfg.get("enabled", False) else []
|
||||
|
||||
relevant = {
|
||||
@@ -1311,6 +1477,7 @@ def admin(site):
|
||||
Admin-Dashboard für einen Standort.
|
||||
Zeigt alle Screens als Cards mit Tabler-Tabs (Playlist, Einstellungen, Medien).
|
||||
"""
|
||||
remember_site(site)
|
||||
cfg = load_config()
|
||||
|
||||
site_list = get_accessible_sites(cfg, current_user)
|
||||
@@ -1337,11 +1504,18 @@ def admin(site):
|
||||
site_cfg = cfg.setdefault("sites", {}).setdefault(current_site, {"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):
|
||||
for entry in os.listdir(site_media_base):
|
||||
entry_path = os.path.join(site_media_base, entry)
|
||||
if os.path.isdir(entry_path) and not entry.startswith("._"):
|
||||
if (os.path.isdir(entry_path)
|
||||
and not entry.startswith("._")
|
||||
and entry not in reserviert):
|
||||
known_screens.add(entry)
|
||||
|
||||
# Screens aus Config ergänzen (auch ohne Ordner)
|
||||
@@ -1371,7 +1545,8 @@ def admin(site):
|
||||
"type": "url",
|
||||
"size": "URL",
|
||||
"zoom": url_data.get("zoom", 1.0),
|
||||
"enabled": is_item_active(item),
|
||||
"enabled": item.get("enabled", True) if isinstance(item, dict) else True,
|
||||
"active": is_item_active(item),
|
||||
"passthrough": item.get("passthrough_params", False) if isinstance(item, dict) else False,
|
||||
"schedule": schedule
|
||||
})
|
||||
@@ -1395,7 +1570,8 @@ def admin(site):
|
||||
"name": item_name,
|
||||
"type": ftype,
|
||||
"size": size,
|
||||
"enabled": is_item_active(item),
|
||||
"enabled": item.get("enabled", True) if isinstance(item, dict) else True,
|
||||
"active": is_item_active(item),
|
||||
"schedule": schedule
|
||||
})
|
||||
|
||||
@@ -1422,12 +1598,21 @@ def admin(site):
|
||||
screen_status[screen_name] = "online" if screen_is_active(current_site, screen_name) else "offline"
|
||||
client_info[screen_name] = get_client_info(current_site, screen_name)
|
||||
|
||||
# Priority-Einträge des Standorts nur zur Anzeige mitgeben: sie gelten für
|
||||
# alle Screens, sind aber in einem eigenen Menüpunkt und werden hier leicht
|
||||
# übersehen. Bedienung bleibt ausschließlich auf der Priority-Seite.
|
||||
prio_cfg = get_priority_config(cfg, current_site) if current_site else {}
|
||||
priority_preview = []
|
||||
if current_site and prio_cfg.get("enabled", False):
|
||||
priority_preview = [f for f in load_priority_files(current_site) if "enabled" in f]
|
||||
|
||||
return render_template(
|
||||
"admin.html",
|
||||
site_list=site_list,
|
||||
current_site=current_site,
|
||||
screens=screens,
|
||||
media_files=media_files,
|
||||
priority_preview=priority_preview,
|
||||
screen_status=screen_status,
|
||||
client_info=client_info,
|
||||
version=APP_VERSION,
|
||||
@@ -1445,9 +1630,10 @@ def admin(site):
|
||||
@site_access_required
|
||||
def admin_priority(site):
|
||||
"""Zeigt die Priority-Playlist-Seite mit Tabs (Playlist + Medien)."""
|
||||
remember_site(site)
|
||||
cfg = load_config()
|
||||
site_list = get_accessible_sites(cfg, current_user)
|
||||
priority_files = load_priority_files()
|
||||
priority_files = load_priority_files(site)
|
||||
|
||||
return render_template(
|
||||
"priority.html",
|
||||
@@ -1527,6 +1713,15 @@ def update_screen(site, screen):
|
||||
cfg["newsticker_text"] = request.form.get("newsticker_text", "")
|
||||
cfg["newsticker_enabled"] = "newsticker_enabled" in request.form
|
||||
cfg["stay_on_first"] = "stay_on_first" in request.form
|
||||
welcome_an = "welcome_enabled" in request.form
|
||||
vorher = cfg.get("welcome_enabled")
|
||||
cfg["welcome_enabled"] = welcome_an
|
||||
|
||||
# Beim Umlegen des Schalters die Willkommensseite sofort platzieren bzw.
|
||||
# entfernen - sie ist je Standort identisch, ein erneutes Generieren
|
||||
# waere also nur ein zusaetzlicher Handgriff ohne anderes Ergebnis.
|
||||
if vorher != welcome_an:
|
||||
generate_welcome_page.sync_welcome_for_screen(config, site, screen, welcome_an)
|
||||
|
||||
save_config(config)
|
||||
return redirect(f"/admin/{site}")
|
||||
@@ -1634,7 +1829,8 @@ def upload(site, screen):
|
||||
filename = secure_filename(file.filename)
|
||||
|
||||
if screen == "priority":
|
||||
file.save(os.path.join(MEDIA_DIR, "priority", filename))
|
||||
os.makedirs(priority_dir(site), exist_ok=True)
|
||||
file.save(os.path.join(priority_dir(site), filename))
|
||||
else:
|
||||
file.save(os.path.join(MEDIA_DIR, site, screen, filename))
|
||||
|
||||
@@ -1705,7 +1901,7 @@ def add_url(site, screen):
|
||||
|
||||
config = load_config()
|
||||
if screen == "priority":
|
||||
config.setdefault("priority", {}).setdefault("playlist", []).append(url_data)
|
||||
get_priority_config(config, site).setdefault("playlist", []).append(url_data)
|
||||
else:
|
||||
cfg = get_screen_config(config, site, screen)
|
||||
cfg.setdefault("playlist", []).append(url_data)
|
||||
@@ -1724,20 +1920,17 @@ def delete_file(site, screen, filename):
|
||||
cfg = load_config()
|
||||
|
||||
if screen == "priority":
|
||||
path = os.path.join(MEDIA_DIR, "priority", filename)
|
||||
playlist = cfg.get("priority", {}).get("playlist", [])
|
||||
playlist[:] = [item for item in playlist if not (
|
||||
(isinstance(item, str) and item == filename) or
|
||||
(isinstance(item, dict) and item.get("url") == filename)
|
||||
)]
|
||||
path = os.path.join(priority_dir(site), filename)
|
||||
playlist = get_priority_config(cfg, site).get("playlist", [])
|
||||
else:
|
||||
path = os.path.join(MEDIA_DIR, site, screen, filename)
|
||||
screens_cfg = cfg.get("sites", {}).get(site, {}).get("screens", {})
|
||||
playlist = screens_cfg.get(screen, {}).get("playlist", [])
|
||||
playlist[:] = [item for item in playlist if not (
|
||||
(isinstance(item, str) and item == filename) or
|
||||
(isinstance(item, dict) and item.get("url") == filename)
|
||||
)]
|
||||
|
||||
# Ueber playlist_item_name vergleichen: Datei-Eintraege stehen als
|
||||
# {"name": ...}, URLs als {"url": ...}, Altbestand als reiner String.
|
||||
playlist[:] = [item for item in playlist
|
||||
if playlist_item_name(item) != filename]
|
||||
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
@@ -1762,7 +1955,7 @@ def save_playlist(site, screen):
|
||||
enabled_map = data.get("enabled", {})
|
||||
|
||||
if screen == "priority":
|
||||
old_playlist = cfg.get("priority", {}).get("playlist", [])
|
||||
old_playlist = get_priority_config(cfg, site).get("playlist", [])
|
||||
else:
|
||||
screens_cfg = cfg.get("sites", {}).get(site, {}).get("screens", {})
|
||||
old_playlist = screens_cfg.get(screen, {}).get("playlist", [])
|
||||
@@ -1783,7 +1976,7 @@ def save_playlist(site, screen):
|
||||
new_playlist.append({"url": key, "zoom": 1.0, "enabled": enabled_map.get(key, True)} if is_url(key) else {"name": key, "enabled": enabled_map.get(key, True)})
|
||||
|
||||
if screen == "priority":
|
||||
cfg.setdefault("priority", {})["playlist"] = new_playlist
|
||||
get_priority_config(cfg, site)["playlist"] = new_playlist
|
||||
else:
|
||||
cfg.setdefault("sites", {}).setdefault(site, {}).setdefault("screens", {}).setdefault(screen, {})["playlist"] = new_playlist
|
||||
|
||||
@@ -1822,7 +2015,7 @@ def update_playlist_item(site, screen):
|
||||
config = load_config()
|
||||
|
||||
if screen == "priority":
|
||||
playlist = config.setdefault("priority", {}).setdefault("playlist", [])
|
||||
playlist = get_priority_config(config, site).setdefault("playlist", [])
|
||||
else:
|
||||
playlist = config["sites"][site]["screens"][screen].setdefault("playlist", [])
|
||||
|
||||
@@ -1925,8 +2118,19 @@ def admin_dashboard():
|
||||
admin_count = sum(1 for u in users_data.values() if u.get("role") == "admin")
|
||||
superuser_count = sum(1 for u in users_data.values() if u.get("role") == "superuser")
|
||||
|
||||
# Kennzahlen und Sparklines bleiben bewusst am jüngsten Ausschnitt haengen,
|
||||
# damit sie sich beim Blaettern im Verlauf nicht veraendern.
|
||||
history = get_history(30)
|
||||
|
||||
gesamt = get_history_count()
|
||||
seiten = max(1, (gesamt + HISTORY_PER_PAGE - 1) // HISTORY_PER_PAGE)
|
||||
try:
|
||||
seite = int(request.args.get("page", 1))
|
||||
except ValueError:
|
||||
seite = 1
|
||||
seite = min(max(seite, 1), seiten)
|
||||
history_page = get_history(HISTORY_PER_PAGE, (seite - 1) * HISTORY_PER_PAGE)
|
||||
|
||||
# Sparkline-Daten aus dem Verlauf berechnen
|
||||
def build_sparkline(entries, creates, deletes):
|
||||
seq = list(reversed(entries))
|
||||
@@ -1975,9 +2179,14 @@ def admin_dashboard():
|
||||
user_trend=user_trend,
|
||||
admin_trend=admin_trend,
|
||||
superuser_trend=superuser_trend,
|
||||
history=history,
|
||||
history=history_page,
|
||||
history_page=seite,
|
||||
history_pages=seiten,
|
||||
history_total=gesamt,
|
||||
history_from=(seite - 1) * HISTORY_PER_PAGE + 1 if gesamt else 0,
|
||||
history_to=min(seite * HISTORY_PER_PAGE, gesamt),
|
||||
site_list=get_accessible_sites(cfg, current_user),
|
||||
current_site=get_site_list(cfg)[0] if get_site_list(cfg) else "",
|
||||
current_site=current_site_for(cfg, current_user),
|
||||
version=APP_VERSION,
|
||||
year=datetime.now().year,
|
||||
hostname=os.uname().nodename,
|
||||
@@ -1993,7 +2202,7 @@ def admin_help():
|
||||
return render_template(
|
||||
"help.html",
|
||||
site_list=get_accessible_sites(cfg, current_user),
|
||||
current_site=get_site_list(cfg)[0] if get_site_list(cfg) else "",
|
||||
current_site=current_site_for(cfg, current_user),
|
||||
version=APP_VERSION,
|
||||
year=datetime.now().year,
|
||||
hostname=os.uname().nodename,
|
||||
@@ -2026,7 +2235,7 @@ def admin_users():
|
||||
"user_list.html",
|
||||
users=sorted(users_list, key=lambda u: (u["last_name"].lower(), u["first_name"].lower())),
|
||||
site_list=get_accessible_sites(cfg, current_user),
|
||||
current_site=get_site_list(cfg)[0] if get_site_list(cfg) else "",
|
||||
current_site=current_site_for(cfg, current_user),
|
||||
version=APP_VERSION,
|
||||
year=datetime.now().year,
|
||||
hostname=os.uname().nodename,
|
||||
|
||||
148
config.json
148
config.json
@@ -1,16 +1,6 @@
|
||||
{
|
||||
"server_url": "http://localhost:5005",
|
||||
"voice_agent_url": "https://dva-stage.buds.cancom.ai/",
|
||||
"priority": {
|
||||
"enabled": true,
|
||||
"playlist": [
|
||||
{
|
||||
"url": "https://wbxroompresence.cancom.io/standort?find=Stuttgart",
|
||||
"zoom": 0.8,
|
||||
"enabled": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"sites": {
|
||||
"stuttgart": {
|
||||
"screens": {
|
||||
@@ -67,11 +57,13 @@
|
||||
"enabled": true
|
||||
}
|
||||
],
|
||||
"newsticker_text": "Herzlich willkommen bei der CANCOM - wir w\u00fcnschen ihnen einen sch\u00f6nen Tag",
|
||||
"newsticker_enabled": true,
|
||||
"newsticker_text": "",
|
||||
"newsticker_enabled": false,
|
||||
"custom_url": "https://www.meteoblue.com/en/meteotv/d7b0fd",
|
||||
"custom_url_label": "Wetter",
|
||||
"custom_url_enabled": false
|
||||
"custom_url_enabled": false,
|
||||
"stay_on_first": false,
|
||||
"welcome_enabled": false
|
||||
},
|
||||
"videosysteme": {
|
||||
"interval": 20,
|
||||
@@ -93,6 +85,25 @@
|
||||
"https://cdn.brandfetch.io/www.ibm.com/logo?c=1idyd4Tpb2nKaXIIc8T",
|
||||
"https://cdn.brandfetch.io/www.soliver.com/logo?c=1idyd4Tpb2nKaXIIc8T"
|
||||
]
|
||||
},
|
||||
"priority": {
|
||||
"enabled": true,
|
||||
"playlist": [
|
||||
{
|
||||
"url": "https://wbxroompresence.cancom.io/standort?find=Stuttgart",
|
||||
"zoom": 0.8,
|
||||
"enabled": false,
|
||||
"passthrough_params": false,
|
||||
"schedule": {
|
||||
"mode": "weekly",
|
||||
"start": "08:00",
|
||||
"end": "09:00",
|
||||
"days": [
|
||||
"0"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"karlsruhe": {
|
||||
@@ -115,6 +126,25 @@
|
||||
"custom_url_target": "overlay",
|
||||
"brand_symbol_enabled": false,
|
||||
"stay_on_first": false
|
||||
},
|
||||
"videosysteme": {
|
||||
"playlist": [
|
||||
{
|
||||
"url": "https://wbxroompresence.cancom.io/signage",
|
||||
"zoom": 1.0,
|
||||
"passthrough_params": true,
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"url": "https://www.meteoblue.com/en/meteotv/d7b0fd",
|
||||
"zoom": 1.0,
|
||||
"passthrough_params": false,
|
||||
"enabled": true
|
||||
}
|
||||
],
|
||||
"interval": 10,
|
||||
"show_images": true,
|
||||
"show_videos": true
|
||||
}
|
||||
},
|
||||
"welcome_data": {
|
||||
@@ -124,6 +154,98 @@
|
||||
"logo_urls": [
|
||||
"https://cdn.brandfetch.io/weisenburger.de/logo?c=1idyd4Tpb2nKaXIIc8T"
|
||||
]
|
||||
},
|
||||
"priority": {
|
||||
"enabled": true,
|
||||
"playlist": [
|
||||
{
|
||||
"url": "https://wbxroompresence.cancom.io/standort?find=Stuttgart",
|
||||
"zoom": 0.8,
|
||||
"enabled": false,
|
||||
"passthrough_params": false,
|
||||
"schedule": {
|
||||
"mode": "weekly",
|
||||
"start": "08:00",
|
||||
"end": "09:00",
|
||||
"days": [
|
||||
"0"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"screens": {
|
||||
"vorstand": {
|
||||
"playlist": [
|
||||
{
|
||||
"name": "welcome.html",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"name": "Artikelbild_Weindorf.jpg",
|
||||
"enabled": true
|
||||
}
|
||||
],
|
||||
"interval": 10,
|
||||
"show_images": true,
|
||||
"show_videos": true,
|
||||
"newsticker_text": "",
|
||||
"newsticker_enabled": false,
|
||||
"stay_on_first": false,
|
||||
"welcome_enabled": true
|
||||
},
|
||||
"casino": {
|
||||
"playlist": [],
|
||||
"interval": 10,
|
||||
"show_images": true,
|
||||
"show_videos": true,
|
||||
"newsticker_text": "",
|
||||
"newsticker_enabled": false,
|
||||
"stay_on_first": false,
|
||||
"welcome_enabled": false
|
||||
}
|
||||
},
|
||||
"welcome_data": {
|
||||
"names": [
|
||||
"WIN",
|
||||
"IBM",
|
||||
"Gaufer"
|
||||
],
|
||||
"logo_urls": [
|
||||
"/media/test/logos/logo_1.png?v=1788363702",
|
||||
"https://cdn.brandfetch.io/www.ibm.com/logo?c=1idyd4Tpb2nKaXIIc8T",
|
||||
"/media/test/logos/logo_3.png?v=1788364382"
|
||||
],
|
||||
"contacts": [
|
||||
"",
|
||||
"",
|
||||
""
|
||||
]
|
||||
},
|
||||
"priority": {
|
||||
"enabled": true,
|
||||
"playlist": [
|
||||
{
|
||||
"url": "https://wbxroompresence.cancom.io/standort?find=Stuttgart",
|
||||
"zoom": 0.8,
|
||||
"enabled": false,
|
||||
"passthrough_params": false,
|
||||
"schedule": {
|
||||
"mode": "weekly",
|
||||
"start": "08:00",
|
||||
"end": "09:00",
|
||||
"days": [
|
||||
"0"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "IMG_6249.jpeg",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -18,5 +18,15 @@ services:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
volumes:
|
||||
# Ohne diese Mounts liegt /app auf dem read-only Root-FS und die App
|
||||
# kann weder Konfiguration noch Verlauf noch Medien schreiben.
|
||||
- ./media:/app/media
|
||||
- ./config.json:/app/config.json
|
||||
- ./users.json:/app/users.json
|
||||
- ./history.json:/app/history.json
|
||||
environment:
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
||||
# Zeitzone: die Zeitsteuerung der Playlist wertet die Serverzeit aus,
|
||||
# ohne TZ laeuft der Container in UTC und Zeitfenster verschieben sich.
|
||||
- TZ=Europe/Berlin
|
||||
|
||||
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>
|
||||
2
env.sh
2
env.sh
@@ -1,2 +0,0 @@
|
||||
export OPENAI_API_KEY="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"
|
||||
export BRANDFETCH_API_KEY="eBwCQW_DcQ_jvdqZRNph0JBtRA36XoMTufaaU4AoirOBnqXX9fMqHDw7uYfiz8kSFYMNKGMZtuHxrmud9hn0WQ"
|
||||
@@ -3,11 +3,13 @@
|
||||
Willkommensseite-Generierung für CANCOM Simple Signage.
|
||||
───────────────────────────────────────────────────────
|
||||
Diese Hilfsdatei sucht anhand von Kundennamen Logos (via OpenAI + Brandfetch),
|
||||
generiert daraus eine HTML-Willkommensseite und fügt sie in die Lobby-Playlist ein.
|
||||
generiert daraus eine HTML-Willkommensseite und fügt sie in die Playlists aller
|
||||
Zielscreens ein (Screens mit "welcome_enabled", Fallback "lobby").
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
from openai import OpenAI
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
@@ -31,6 +33,204 @@ def get_openai_client():
|
||||
# Brandfetch-Logo-Suche
|
||||
# -------------------------------------------------
|
||||
|
||||
# -------------------------------------------------
|
||||
# Logo-Upload: Freistellen
|
||||
# -------------------------------------------------
|
||||
|
||||
LOGO_MAX_SIZE = 800 # Kantenlänge, auf die grosse Uploads verkleinert werden
|
||||
LOGO_TOLERANCE = 32 # Farbabstand, bis zu dem ein Pixel als Hintergrund gilt
|
||||
LOGO_BORDER_UNIFORMITY = 48 # Streuung, bis zu der ein Rand als einfarbig durchgeht
|
||||
|
||||
|
||||
def _color_distance(a, b):
|
||||
"""Abstand zweier RGB-Werte (Manhattan über die Kanäle, gemittelt)."""
|
||||
return (abs(a[0] - b[0]) + abs(a[1] - b[1]) + abs(a[2] - b[2])) / 3.0
|
||||
|
||||
|
||||
def _border_color(px, w, h):
|
||||
"""Ermittelt die Hintergrundfarbe aus den Randpixeln.
|
||||
Gibt (farbe, einfarbig) zurück - einfarbig=False, wenn der Rand zu
|
||||
stark streut und ein Freistellen deshalb nicht sinnvoll ist."""
|
||||
rand = []
|
||||
schritt = max(1, min(w, h) // 100)
|
||||
for x in range(0, w, schritt):
|
||||
rand.append(px[x, 0][:3])
|
||||
rand.append(px[x, h - 1][:3])
|
||||
for y in range(0, h, schritt):
|
||||
rand.append(px[0, y][:3])
|
||||
rand.append(px[w - 1, y][:3])
|
||||
|
||||
mitte = tuple(sorted(k[i] for k in rand)[len(rand) // 2] for i in range(3))
|
||||
streuung = sum(_color_distance(k, mitte) for k in rand) / len(rand)
|
||||
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):
|
||||
"""Stellt ein Logo frei: entfernt den zusammenhängenden Hintergrund von den
|
||||
Rändern her und macht ihn transparent.
|
||||
|
||||
Bewusst ein Flood Fill von den Rändern und kein globaler Farbvergleich -
|
||||
so bleibt Weiss *innerhalb* des Logos (etwa Text in einer Fläche) erhalten.
|
||||
|
||||
Gibt (bild, hinweis) zurück. hinweis ist None bei Erfolg, sonst ein Text
|
||||
für die Oberfläche - das Bild wird dann unverändert zurückgegeben.
|
||||
"""
|
||||
from collections import deque
|
||||
from PIL import Image
|
||||
|
||||
img = image.convert("RGBA")
|
||||
|
||||
if max(img.size) > LOGO_MAX_SIZE:
|
||||
img.thumbnail((LOGO_MAX_SIZE, LOGO_MAX_SIZE), Image.LANCZOS)
|
||||
|
||||
# Bereits transparent? Dann nicht anfassen.
|
||||
alpha = img.getchannel("A")
|
||||
if alpha.getextrema()[0] < 255:
|
||||
return img, None
|
||||
|
||||
w, h = img.size
|
||||
px = img.load()
|
||||
bg, einfarbig = _border_color(px, w, h)
|
||||
|
||||
if not einfarbig:
|
||||
return img, ("Der Bildrand ist nicht einfarbig – das Logo wurde unverändert "
|
||||
"übernommen. Am besten ein PNG mit transparentem Hintergrund hochladen.")
|
||||
|
||||
# Vor dem Eingriff sichern: ist das Logo farblich zu nah am Hintergrund,
|
||||
# frisst der Flood Fill es mit auf und wir muessen zurueckfallen koennen.
|
||||
original = img.copy()
|
||||
|
||||
# Flood Fill von allen Randpixeln aus
|
||||
besucht = bytearray(w * h)
|
||||
queue = deque()
|
||||
for x in range(w):
|
||||
for y in (0, h - 1):
|
||||
queue.append((x, y))
|
||||
for y in range(h):
|
||||
for x in (0, w - 1):
|
||||
queue.append((x, y))
|
||||
|
||||
while queue:
|
||||
x, y = queue.popleft()
|
||||
i = y * w + x
|
||||
if besucht[i]:
|
||||
continue
|
||||
besucht[i] = 1
|
||||
if _color_distance(px[x, y], bg) > tolerance:
|
||||
continue
|
||||
px[x, y] = (0, 0, 0, 0)
|
||||
if x > 0: queue.append((x - 1, y))
|
||||
if x < w - 1: queue.append((x + 1, y))
|
||||
if y > 0: queue.append((x, y - 1))
|
||||
if y < h - 1: queue.append((x, y + 1))
|
||||
|
||||
# Weiche Kante: Pixel direkt an der Freistellung teilweise durchscheinen
|
||||
# lassen, sonst bleibt ein harter Treppenrand stehen.
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
r, g, b, a = px[x, y]
|
||||
if a == 0:
|
||||
continue
|
||||
nachbar_frei = (
|
||||
(x > 0 and px[x - 1, y][3] == 0) or
|
||||
(x < w - 1 and px[x + 1, y][3] == 0) or
|
||||
(y > 0 and px[x, y - 1][3] == 0) or
|
||||
(y < h - 1 and px[x, y + 1][3] == 0)
|
||||
)
|
||||
if not nachbar_frei:
|
||||
continue
|
||||
d = _color_distance((r, g, b), bg)
|
||||
if d < tolerance * 2:
|
||||
px[x, y] = (r, g, b, int(255 * max(0.0, (d - tolerance) / tolerance)))
|
||||
|
||||
# Wie viel ist uebrig geblieben? Ein helles Logo auf hellem Grund liegt
|
||||
# innerhalb der Toleranz und wuerde komplett mit entfernt - das Ergebnis
|
||||
# waere ein leeres Bild und auf der Willkommensseite gar kein Logo.
|
||||
histogramm = img.getchannel("A").histogram()
|
||||
sichtbar = sum(histogramm[1:])
|
||||
anteil = sichtbar / float(w * h)
|
||||
|
||||
if anteil < 0.02:
|
||||
return original, ("Das Logo ist farblich zu nah am Hintergrund – beim Freistellen "
|
||||
"wäre fast nichts übrig geblieben. Es wurde deshalb unverändert "
|
||||
"übernommen. Am besten ein PNG mit transparentem Hintergrund "
|
||||
"hochladen oder die Option \u201eHintergrund behalten\u201c ankreuzen.")
|
||||
|
||||
if anteil > 0.999:
|
||||
return img, "Es wurde kein zusammenhängender Hintergrund gefunden."
|
||||
|
||||
return img, None
|
||||
|
||||
|
||||
# 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")
|
||||
|
||||
|
||||
def save_uploaded_logo(file_storage, site, slot, freistellen=True):
|
||||
"""Nimmt ein hochgeladenes Logo entgegen, stellt es optional frei und legt es
|
||||
unter media/<site>/logos/logo_<slot>.png ab.
|
||||
|
||||
Gespeichert wird immer als PNG - JPEG kann keine Transparenz.
|
||||
Gibt (url, hinweis) zurück; url ist None, wenn die Datei unbrauchbar war.
|
||||
Der Zeitstempel in der URL verhindert, dass Player ein altes Logo
|
||||
aus dem Cache anzeigen."""
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
|
||||
name = (file_storage.filename or "").lower()
|
||||
if not name.endswith(LOGO_EXTENSIONS):
|
||||
return None, f"Format nicht unterstützt ({', '.join(LOGO_EXTENSIONS)})."
|
||||
|
||||
try:
|
||||
img = Image.open(file_storage.stream)
|
||||
img.load()
|
||||
except (UnidentifiedImageError, OSError) as e:
|
||||
return None, f"Datei konnte nicht gelesen werden: {e}"
|
||||
|
||||
hinweis = None
|
||||
if freistellen:
|
||||
img, hinweis = remove_logo_background(img)
|
||||
else:
|
||||
img = img.convert("RGBA")
|
||||
|
||||
# Nach dem Freistellen zuschneiden, damit das Logo genauso gross erscheint
|
||||
# wie ein automatisch gefundenes
|
||||
vorher = img.size
|
||||
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]}")
|
||||
|
||||
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}"
|
||||
+ (f" (Hinweis: {hinweis})" if hinweis else ""))
|
||||
return url, hinweis
|
||||
|
||||
|
||||
def get_brandfetch_logo(domain):
|
||||
"""Versucht, ein Firmenlogo über Brandfetch CDN abzurufen.
|
||||
Baut eine direkte CDN-URL: https://cdn.brandfetch.io/{domain}/logo"""
|
||||
@@ -296,57 +496,226 @@ def generate_welcome_html(customer_names, logo_urls, contacts=None, site="stuttg
|
||||
# Speichern + Playlist-Einfügung
|
||||
# -------------------------------------------------
|
||||
|
||||
def save_welcome_page(customer_names, logo_urls, contacts=None, site="stuttgart", background_url=None):
|
||||
"""Speichert die generierte Willkommensseite als welcome.html im Lobby-Verzeichnis.
|
||||
def welcome_target_screens(config, site):
|
||||
"""Ermittelt die Screens eines Standorts, auf denen die Willkommensseite laufen soll.
|
||||
|
||||
Ein Screen ist Ziel, wenn "welcome_enabled" gesetzt ist. Ist der Schluessel bei
|
||||
keinem Screen des Standorts vorhanden, greift das alte Verhalten: nur "lobby".
|
||||
Damit bleiben bestehende Standorte ohne Konfigurationsaufwand unveraendert."""
|
||||
screens = config.get("sites", {}).get(site, {}).get("screens", {})
|
||||
|
||||
configured = [name for name, cfg in screens.items()
|
||||
if isinstance(cfg, dict) and cfg.get("welcome_enabled")]
|
||||
if configured:
|
||||
return sorted(configured)
|
||||
|
||||
# Kein Screen markiert: nur dann auf "lobby" zurückfallen, wenn noch nirgends
|
||||
# eine bewusste Entscheidung getroffen wurde. Wurde das Häkchen überall
|
||||
# entfernt, ist "keine Screens" das gewollte Ergebnis.
|
||||
entschieden = any(isinstance(cfg, dict) and "welcome_enabled" in cfg
|
||||
for cfg in screens.values())
|
||||
if entschieden:
|
||||
return []
|
||||
|
||||
return ["lobby"] if "lobby" in screens else []
|
||||
|
||||
|
||||
def save_welcome_page(customer_names, logo_urls, contacts=None, site="stuttgart",
|
||||
background_url=None, screens=None):
|
||||
"""Speichert die generierte Willkommensseite als welcome.html in den Zielscreens.
|
||||
|
||||
screens: Liste der Screen-Namen; None → Zielscreens aus der Konfiguration.
|
||||
contacts: Liste der Ansprechpartner-Texte (Markdown, optional)
|
||||
background_url: Benutzerdefiniertes Hintergrundbild oder None (Standard).
|
||||
Gibt den Dateinamen zurück oder None bei Fehler."""
|
||||
Gibt den Dateinamen zurück oder None bei Fehler.
|
||||
|
||||
Die Seite referenziert alle Bilder absolut (/static/..., /media/<site>/...),
|
||||
ist also ortsunabhängig - eine Kopie je Screen rendert identisch."""
|
||||
try:
|
||||
welcome_dir = os.path.join(MEDIA_DIR, site, "lobby")
|
||||
from app import load_config
|
||||
|
||||
if screens is None:
|
||||
screens = welcome_target_screens(load_config(), site)
|
||||
|
||||
if not screens:
|
||||
print(f"⚠️ Generate - Kein Zielscreen für die Willkommensseite (site: {site})")
|
||||
return None
|
||||
|
||||
html_content = generate_welcome_html(customer_names, logo_urls, contacts=contacts,
|
||||
site=site, background_url=background_url)
|
||||
|
||||
# Standort-Kopie zuerst - sie ueberlebt das Abschalten aller Screens
|
||||
master = _welcome_master_path(site)
|
||||
os.makedirs(os.path.dirname(master), exist_ok=True)
|
||||
with open(master, "w", encoding="utf-8") as f:
|
||||
f.write(html_content)
|
||||
|
||||
for screen in screens:
|
||||
welcome_dir = os.path.join(MEDIA_DIR, site, screen)
|
||||
os.makedirs(welcome_dir, exist_ok=True)
|
||||
filepath = os.path.join(os.path.abspath(welcome_dir), WELCOME_FILENAME)
|
||||
html_content = generate_welcome_html(customer_names, logo_urls, contacts=contacts, site=site, background_url=background_url)
|
||||
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
f.write(html_content)
|
||||
if os.path.getsize(filepath) == 0:
|
||||
raise IOError("File not written")
|
||||
names_str = ", ".join(customer_names)
|
||||
print(f"✅ Generate - Willkommensseite generiert für: {names_str} (site: {site})")
|
||||
print(f"✅ Generate - Willkommensseite gespeichert in: {filepath}")
|
||||
|
||||
names_str = ", ".join(customer_names)
|
||||
print(f"✅ Generate - Willkommensseite generiert für: {names_str} "
|
||||
f"(site: {site}, screens: {', '.join(screens)})")
|
||||
return WELCOME_FILENAME
|
||||
except Exception as e:
|
||||
print(f"❌ Error saving welcome page: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def add_customer_to_lobby_playlist(html_filename, site="stuttgart"):
|
||||
"""Fügt die Willkommensseite (welcome.html) an Position 0 in die Lobby-Playlist ein.
|
||||
Entfernt vorher alle vorhandenen welcome.html-Einträge (als String oder Dict).
|
||||
def _strip_welcome(playlist, filename):
|
||||
"""Entfernt alle welcome.html-Einträge aus einer Playlist (String- und Dict-Form)."""
|
||||
playlist[:] = [
|
||||
item for item in playlist
|
||||
if not (
|
||||
(isinstance(item, str) and item == filename) or
|
||||
(isinstance(item, dict) and item.get("name") == filename)
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _welcome_path(site, screen):
|
||||
return os.path.join(MEDIA_DIR, site, screen, WELCOME_FILENAME)
|
||||
|
||||
|
||||
def _welcome_master_path(site):
|
||||
"""Standort-Kopie der Willkommensseite. Sie liegt neben den Screen-Ordnern,
|
||||
wird beim Generieren mitgeschrieben und nie geloescht - damit der Schalter
|
||||
eines Screens die Seite auch dann noch platzieren kann, wenn sie zuvor
|
||||
auf allen Screens abgeschaltet war."""
|
||||
return os.path.join(MEDIA_DIR, site, WELCOME_FILENAME)
|
||||
|
||||
|
||||
def find_welcome_source(site, config):
|
||||
"""Sucht eine vorhandene welcome.html für einen Standort.
|
||||
Die Seite ist je Standort identisch, jede Kopie taugt als Quelle.
|
||||
Gibt den Pfad zurück oder None, wenn noch nie eine erzeugt wurde."""
|
||||
master = _welcome_master_path(site)
|
||||
if os.path.exists(master):
|
||||
return master
|
||||
|
||||
screens = config.get("sites", {}).get(site, {}).get("screens", {})
|
||||
for screen in sorted(screens):
|
||||
pfad = _welcome_path(site, screen)
|
||||
if os.path.exists(pfad):
|
||||
return pfad
|
||||
return None
|
||||
|
||||
|
||||
def _remove_welcome_file(site, screen):
|
||||
"""Löscht die welcome.html eines Screens, falls vorhanden."""
|
||||
pfad = _welcome_path(site, screen)
|
||||
if os.path.exists(pfad):
|
||||
try:
|
||||
os.remove(pfad)
|
||||
except OSError as e:
|
||||
print(f"⚠️ Generate - welcome.html in {site}/{screen} nicht löschbar: {e}")
|
||||
|
||||
|
||||
def sync_welcome_for_screen(config, site, screen, enabled):
|
||||
"""Bringt einen einzelnen Screen mit dem Schalter "Willkommensseite hier
|
||||
anzeigen" in Einklang - ohne dass neu generiert werden muss.
|
||||
|
||||
enabled=True : vorhandene welcome.html hineinkopieren und vorne einfügen
|
||||
enabled=False: Eintrag entfernen und die Kopie löschen
|
||||
|
||||
Verändert nur das übergebene config-Dict; das Speichern übernimmt der
|
||||
Aufrufer. Gibt True zurück, wenn sich an der Playlist etwas geändert hat."""
|
||||
screen_cfg = config.get("sites", {}).get(site, {}).get("screens", {}).get(screen)
|
||||
if screen_cfg is None:
|
||||
return False
|
||||
|
||||
playlist = screen_cfg.setdefault("playlist", [])
|
||||
vorher = list(playlist)
|
||||
|
||||
if not enabled:
|
||||
_strip_welcome(playlist, WELCOME_FILENAME)
|
||||
_remove_welcome_file(site, screen)
|
||||
return playlist != vorher
|
||||
|
||||
quelle = find_welcome_source(site, config)
|
||||
if not quelle:
|
||||
# Noch keine Willkommensseite erzeugt - dann gibt es nichts zu platzieren.
|
||||
return False
|
||||
|
||||
ziel = _welcome_path(site, screen)
|
||||
if os.path.abspath(quelle) != os.path.abspath(ziel):
|
||||
try:
|
||||
os.makedirs(os.path.dirname(ziel), exist_ok=True)
|
||||
with open(quelle, "rb") as q, open(ziel, "wb") as z:
|
||||
z.write(q.read())
|
||||
except OSError as e:
|
||||
print(f"❌ Generate - welcome.html nach {site}/{screen} nicht kopierbar: {e}")
|
||||
return False
|
||||
|
||||
# Vorhandenen Eintrag entfernen und vorne neu einfügen
|
||||
_strip_welcome(playlist, WELCOME_FILENAME)
|
||||
playlist.insert(0, {"name": WELCOME_FILENAME, "enabled": True})
|
||||
print(f"✅ Generate - Willkommensseite in {site}/{screen} platziert")
|
||||
return True
|
||||
|
||||
|
||||
def add_customer_to_playlists(html_filename, site="stuttgart", screens=None, valid_until=None):
|
||||
"""Setzt die Willkommensseite an Position 0 der Playlist jedes Zielscreens.
|
||||
|
||||
screens: Liste der Zielscreens; None → aus der Konfiguration ermitteln.
|
||||
valid_until: optionales Datum "YYYY-MM-DD". Ist es gesetzt, bekommt der
|
||||
Eintrag eine Zeitsteuerung und verschwindet danach von selbst.
|
||||
|
||||
Aus allen übrigen Screens des Standorts wird die Seite entfernt, damit ein
|
||||
abgewählter Screen nicht mit einer alten Begrüßung stehen bleibt.
|
||||
Importiert load_config/save_config aus app.py (kein Zirkelimport)."""
|
||||
try:
|
||||
from app import load_config, save_config
|
||||
|
||||
config = load_config()
|
||||
sites_cfg = config.setdefault("sites", {})
|
||||
site_cfg = sites_cfg.setdefault(site, {"screens": {}})
|
||||
screens = site_cfg.setdefault("screens", {})
|
||||
lobby = screens.setdefault("lobby", {})
|
||||
playlist = lobby.setdefault("playlist", [])
|
||||
site_cfg = config.setdefault("sites", {}).setdefault(site, {"screens": {}})
|
||||
all_screens = site_cfg.setdefault("screens", {})
|
||||
|
||||
if screens is None:
|
||||
screens = welcome_target_screens(config, site)
|
||||
|
||||
# Nur Screens, die es wirklich gibt - kein stillschweigendes Anlegen
|
||||
targets = [name for name in screens if name in all_screens]
|
||||
|
||||
entry = {"name": html_filename, "enabled": True}
|
||||
if valid_until:
|
||||
# Ganztaegig bis einschliesslich valid_until
|
||||
entry["schedule"] = {
|
||||
"mode": "daily",
|
||||
"start": "00:00",
|
||||
"end": "23:59",
|
||||
"valid_until": valid_until
|
||||
}
|
||||
|
||||
for name, screen_cfg in all_screens.items():
|
||||
if not isinstance(screen_cfg, dict):
|
||||
continue
|
||||
playlist = screen_cfg.setdefault("playlist", [])
|
||||
_strip_welcome(playlist, html_filename)
|
||||
if name in targets:
|
||||
playlist.insert(0, dict(entry))
|
||||
else:
|
||||
# Auch die Datei entfernen - sonst bliebe auf einem abgewaehlten
|
||||
# Screen eine verwaiste Kopie liegen, die bei leerer Playlist
|
||||
# sogar noch abgespielt wuerde.
|
||||
_remove_welcome_file(site, name)
|
||||
|
||||
full_filename = f"{html_filename}"
|
||||
# Alte welcome.html-Einträge entfernen (sowohl String- als auch Dict-Form)
|
||||
playlist[:] = [
|
||||
item for item in playlist
|
||||
if not (
|
||||
(isinstance(item, str) and item == full_filename) or
|
||||
(isinstance(item, dict) and item.get("name") == full_filename)
|
||||
)
|
||||
]
|
||||
playlist.insert(0, full_filename)
|
||||
save_config(config)
|
||||
print(f"✅ Generate - Willkommensseite in Lobby-Playliste platziert (site: {site})")
|
||||
return True
|
||||
|
||||
if targets:
|
||||
print(f"✅ Generate - Willkommensseite platziert (site: {site}, "
|
||||
f"screens: {', '.join(targets)})")
|
||||
else:
|
||||
print(f"⚠️ Generate - Kein Zielscreen vorhanden (site: {site})")
|
||||
return bool(targets)
|
||||
except Exception as e:
|
||||
print(f"❌ Generate - Error adding to playlist: {e}")
|
||||
return False
|
||||
|
||||
312
history.json
312
history.json
@@ -1,4 +1,316 @@
|
||||
[
|
||||
{
|
||||
"timestamp": "2026-09-07 10:45:56",
|
||||
"action": "user_login",
|
||||
"detail": "User 't@x.de' angemeldet (Trusted Device)",
|
||||
"user": "t@x.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-09-07 10:45:56",
|
||||
"action": "user_login",
|
||||
"detail": "User 't@x.de' angemeldet (mit MFA, Ger\u00e4t f\u00fcr 7 Tage gespeichert)",
|
||||
"user": "t@x.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-09-07 10:44:24",
|
||||
"action": "user_login",
|
||||
"detail": "User 't@x.de' angemeldet (Trusted Device)",
|
||||
"user": "t@x.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-09-07 10:44:24",
|
||||
"action": "user_login",
|
||||
"detail": "User 't@x.de' angemeldet (mit MFA, Ger\u00e4t f\u00fcr 7 Tage gespeichert)",
|
||||
"user": "t@x.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-09-04 10:38:34",
|
||||
"action": "user_login",
|
||||
"detail": "User 'erik.thiele@cancom.de' angemeldet",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-09-02 17:58:05",
|
||||
"action": "screen_deleted",
|
||||
"detail": "Screen 'xxx' an Standort 'test' gel\u00f6scht",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-09-02 17:57:39",
|
||||
"action": "screen_created",
|
||||
"detail": "Screen 'xxx' an Standort 'test' angelegt",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-09-02 17:56:56",
|
||||
"action": "background_uploaded",
|
||||
"detail": "Hintergrundbild f\u00fcr Standort 'test' hochgeladen",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-09-02 17:53:02",
|
||||
"action": "customer_added",
|
||||
"detail": "Kunden (WIN, IBM, Gaufer) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: casino, vorstand)",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-09-02 17:44:39",
|
||||
"action": "screen_created",
|
||||
"detail": "Screen 'casino' an Standort 'test' angelegt",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-09-02 17:44:31",
|
||||
"action": "site_deleted",
|
||||
"detail": "Standort 'casino' gel\u00f6scht",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-09-02 17:44:25",
|
||||
"action": "site_created",
|
||||
"detail": "Standort 'casino' angelegt",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-09-02 17:41:44",
|
||||
"action": "customer_added",
|
||||
"detail": "Kunden (WIN, IBM) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-09-02 17:41:32",
|
||||
"action": "customer_added",
|
||||
"detail": "Kunden (IBM) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-09-02 17:40:55",
|
||||
"action": "customer_added",
|
||||
"detail": "Kunden (XXTT) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-09-02 16:10:33",
|
||||
"action": "customer_added",
|
||||
"detail": "Kunden (Teamshop Kuenzelsau) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-09-02 15:59:00",
|
||||
"action": "customer_added",
|
||||
"detail": "Kunden (Teamshop Kuenzelsau) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-09-02 15:52:39",
|
||||
"action": "customer_added",
|
||||
"detail": "Kunden (Teamshop Kuenzelsau, IBM) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-09-02 15:52:13",
|
||||
"action": "customer_added",
|
||||
"detail": "Kunden (Teamshop Kuenzelsau, IBM) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-09-02 15:52:04",
|
||||
"action": "customer_added",
|
||||
"detail": "Kunden (Teamshop Kuenzelsau, IBM) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-09-02 15:42:42",
|
||||
"action": "customer_added",
|
||||
"detail": "Kunden (Teamshop Kuenzelsau) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-09-02 15:42:25",
|
||||
"action": "customer_added",
|
||||
"detail": "Kunden (Teamshop Kuenzelsau) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-09-02 15:36:52",
|
||||
"action": "customer_added",
|
||||
"detail": "Kunden (Teamshop Kuenzelsau) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
|
||||
"user": "admin"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-09-02 15:32:23",
|
||||
"action": "customer_added",
|
||||
"detail": "Kunden (IBM) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-09-02 15:32:06",
|
||||
"action": "customer_added",
|
||||
"detail": "Kunden (IBM) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-09-02 15:31:59",
|
||||
"action": "customer_added",
|
||||
"detail": "Kunden (IBM) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-09-02 15:31:44",
|
||||
"action": "customer_added",
|
||||
"detail": "Kunden (IBM) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-09-02 15:31:31",
|
||||
"action": "customer_added",
|
||||
"detail": "Kunden (IBM) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-09-02 15:31:14",
|
||||
"action": "customer_added",
|
||||
"detail": "Kunden (IBM) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-09-02 15:30:36",
|
||||
"action": "customer_added",
|
||||
"detail": "Kunden (Teamshop Kuenzelsau) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-09-02 15:28:22",
|
||||
"action": "customer_added",
|
||||
"detail": "Kunden (Teamshop Kuenzelsau) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
|
||||
"user": "admin"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-09-02 15:26:50",
|
||||
"action": "customer_added",
|
||||
"detail": "Kunden (HellesLogo) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
|
||||
"user": "admin"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-09-02 15:23:36",
|
||||
"action": "customer_added",
|
||||
"detail": "Kunden (UploadTest) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
|
||||
"user": "admin"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-09-02 15:22:22",
|
||||
"action": "customer_added",
|
||||
"detail": "Kunden (ibm) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-09-02 15:21:07",
|
||||
"action": "customer_added",
|
||||
"detail": "Kunden (ibm) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-09-02 15:19:35",
|
||||
"action": "customer_added",
|
||||
"detail": "Kunden (ibm) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-09-02 15:19:21",
|
||||
"action": "customer_added",
|
||||
"detail": "Kunden (ibm) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-09-02 15:09:15",
|
||||
"action": "customer_added",
|
||||
"detail": "Kunden (ibm) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: logos, vorstand)",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-09-02 15:08:35",
|
||||
"action": "customer_added",
|
||||
"detail": "Kunden (ibm) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: logos, vorstand)",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-09-02 15:08:14",
|
||||
"action": "customer_added",
|
||||
"detail": "Kunden (ibm) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: logos, vorstand)",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-09-02 15:07:50",
|
||||
"action": "customer_added",
|
||||
"detail": "Kunden (ibm, lenovo, logitech) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: logos, vorstand)",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-09-02 15:02:34",
|
||||
"action": "customer_added",
|
||||
"detail": "Kunden (ibm, lenovo, logitech) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: logos, vorstand)",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-09-02 15:02:01",
|
||||
"action": "customer_added",
|
||||
"detail": "Kunden (ibm, lenovo, logitech) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: logos, vorstand)",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-09-02 15:01:07",
|
||||
"action": "customer_added",
|
||||
"detail": "Kunden (ibm, lenovo, logitech) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: logos, vorstand)",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-09-02 14:35:54",
|
||||
"action": "customer_added",
|
||||
"detail": "Kunden (Testkunde) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
|
||||
"user": "admin"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-09-02 14:31:04",
|
||||
"action": "screen_created",
|
||||
"detail": "Screen 'vorstand' an Standort 'test' angelegt",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-09-02 14:30:58",
|
||||
"action": "site_created",
|
||||
"detail": "Standort 'test' angelegt",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-09-02 14:30:51",
|
||||
"action": "user_login",
|
||||
"detail": "User 'erik.thiele@cancom.de' angemeldet",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-09-02 14:29:42",
|
||||
"action": "password_changed",
|
||||
"detail": "Passwort f\u00fcr User 'erik.thiele@cancom.de' ge\u00e4ndert",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-09-02 14:29:34",
|
||||
"action": "user_login",
|
||||
"detail": "User 'erik.thiele@cancom.de' angemeldet (Passwort-\u00c4nderung erforderlich)",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-08-29 16:20:09",
|
||||
"action": "user_login",
|
||||
"detail": "User 'admin' angemeldet",
|
||||
"user": "admin"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-07-10 17:34:19",
|
||||
"action": "screen_created",
|
||||
"detail": "Screen 'videosysteme' an Standort 'karlsruhe' angelegt",
|
||||
"user": "erik.thiele@cancom.de"
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-07-10 16:15:59",
|
||||
"action": "user_login",
|
||||
|
||||
@@ -52,6 +52,19 @@
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* ─── Prio-Vorschau: reine Anzeige, keine Bedienung ─── */
|
||||
.playlist-prio {
|
||||
display: grid;
|
||||
grid-template-columns: 28px 1fr 90px 80px;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
opacity: 0.72;
|
||||
background: var(--tblr-bg-surface-secondary, #f8f9fa);
|
||||
}
|
||||
.playlist-prio .playlist-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.playlist-name {
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
@@ -229,6 +242,32 @@
|
||||
|
||||
<!-- ═══ Tab: Playlist ═══ -->
|
||||
<div class="tab-pane active" id="tab-playlist-{{ screen }}" role="tabpanel">
|
||||
{% if priority_preview %}
|
||||
<!-- Priority-Playlist des Standorts: laeuft diesen Screens vorweg.
|
||||
Reine Anzeige - Bedienung ausschliesslich unter "Priority". -->
|
||||
<ul class="list-group mb-2">
|
||||
{% for file in priority_preview %}
|
||||
<li class="list-group-item playlist-prio">
|
||||
<i class="ti ti-player-play" title="Laeuft vorweg"></i>
|
||||
<span class="playlist-name">
|
||||
{{ file.name }}
|
||||
{% if file.schedule %}<span class="badge ms-1 {% if file.active %}bg-info text-white{% else %}bg-secondary-lt{% endif %}" title="{{ file.schedule | schedule_text }}{% if not file.active %} — laeuft gerade nicht{% endif %}">Zeit</span>{% endif %}
|
||||
{% if not file.enabled %}<span class="badge bg-secondary-lt ms-1">inaktiv</span>{% endif %}
|
||||
</span>
|
||||
<span class="badge bg-red text-red-fg">Prio</span>
|
||||
<span class="playlist-size">
|
||||
{% if file.type == "url" %}{{ file.size }}{% else %}{{ file.size }} KB{% endif %}
|
||||
</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<p class="text-muted small mb-3">
|
||||
Diese Einträge kommen aus der
|
||||
<a href="{{ url_for('admin_priority', site=current_site) }}">Priority-Playlist</a>
|
||||
des Standorts und laufen allen Screens vorweg. Bearbeiten nur dort.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<ul class="list-group mb-3" id="playlist-{{ screen }}">
|
||||
{% for file in media_files[screen] %}
|
||||
<li class="list-group-item playlist-row"
|
||||
@@ -251,7 +290,7 @@
|
||||
title="URL: {{ file.name }}{% if file.zoom %} (Zoom: {{ file.zoom }}x){% endif %}"
|
||||
{% endif %}>
|
||||
{{ file.name }}{% if file.type == "url" and file.zoom != 1.0 %} <strong style="color: #DA002D;">[{{ file.zoom }}x]</strong>{% endif %}{% if file.type == "url" %}{% if file.get('passthrough', False) %} <i class="ti ti-link" style="color:#6c757d;" title="Query-Parameter werden übergeben"></i>{% else %} <i class="ti ti-link-off" style="color:#adb5bd;" title="Query-Parameter werden nicht übergeben"></i>{% endif %}{% endif %}
|
||||
{% if file.schedule %}<span class="badge bg-info text-white ms-1" title="{{ file.schedule | schedule_text }}">Zeit</span>{% endif %}
|
||||
{% if file.schedule %}<span class="badge ms-1 {% if file.active %}bg-info text-white{% else %}bg-secondary-lt{% endif %}" title="{{ file.schedule | schedule_text }}{% if not file.active %} — laeuft gerade nicht{% endif %}">Zeit</span>{% endif %}
|
||||
</span>
|
||||
|
||||
<!-- Typ-Badge -->
|
||||
@@ -344,13 +383,21 @@
|
||||
<label class="form-check-label">Videos anzeigen</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check form-switch mb-4">
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox"
|
||||
name="stay_on_first"
|
||||
{% if cfg.stay_on_first %}checked{% endif %}>
|
||||
<label class="form-check-label">Auf erstem Element stehen bleiben (kein Refresh)</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check form-switch mb-1">
|
||||
<input class="form-check-input" type="checkbox"
|
||||
name="welcome_enabled"
|
||||
{% if cfg.welcome_enabled %}checked{% endif %}>
|
||||
<label class="form-check-label">Willkommensseite hier anzeigen</label>
|
||||
</div>
|
||||
<div class="form-hint mb-4">Erzeugte Willkommensseiten werden auf allen so markierten Screens dieses Standorts vorne in die Playlist gesetzt.</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">
|
||||
Einstellungen speichern
|
||||
</button>
|
||||
|
||||
@@ -121,6 +121,9 @@
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title"><i class="ti ti-history me-1"></i>Verlauf</h3>
|
||||
{% if history_total %}
|
||||
<span class="card-subtitle text-secondary small ms-auto">{{ history_from }}–{{ history_to }} von {{ history_total }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="list-group list-group-flush">
|
||||
@@ -171,6 +174,29 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% if history_pages > 1 %}
|
||||
<div class="card-footer d-flex align-items-center">
|
||||
<p class="m-0 text-secondary small">Seite {{ history_page }} von {{ history_pages }}</p>
|
||||
<ul class="pagination m-0 ms-auto">
|
||||
<li class="page-item {% if history_page <= 1 %}disabled{% endif %}">
|
||||
<a class="page-link" href="?page={{ history_page - 1 }}"><i class="ti ti-chevron-left"></i> zurück</a>
|
||||
</li>
|
||||
{# Nur ein Fenster um die aktuelle Seite zeigen, sonst wird die Leiste bei vielen Seiten zu lang #}
|
||||
{% for p in range(1, history_pages + 1) %}
|
||||
{% if p == 1 or p == history_pages or (p >= history_page - 2 and p <= history_page + 2) %}
|
||||
<li class="page-item {% if p == history_page %}active{% endif %}">
|
||||
<a class="page-link" href="?page={{ p }}">{{ p }}</a>
|
||||
</li>
|
||||
{% elif p == history_page - 3 or p == history_page + 3 %}
|
||||
<li class="page-item disabled"><span class="page-link">…</span></li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<li class="page-item {% if history_page >= history_pages %}disabled{% endif %}">
|
||||
<a class="page-link" href="?page={{ history_page + 1 }}">weiter <i class="ti ti-chevron-right"></i></a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -83,7 +83,8 @@
|
||||
<!-- ═══════════════════════════════════════════════════
|
||||
CONTENT – Willkommensseite-Formular
|
||||
Bis zu 3 Kundennamen eingeben → Logos via OpenAI + Brandfetch
|
||||
→ welcome.html wird generiert und in Lobby-Playlist platziert
|
||||
→ welcome.html wird generiert und in den Playlists aller Screens
|
||||
mit "welcome_enabled" vorne platziert
|
||||
═══════════════════════════════════════════════════ -->
|
||||
<div class="page-wrapper">
|
||||
<div class="container-xl mt-4">
|
||||
@@ -103,6 +104,14 @@
|
||||
{% if success %}
|
||||
<div class="alert alert-success">{{ success }}</div>
|
||||
{% endif %}
|
||||
{% if logo_hinweise %}
|
||||
<div class="alert alert-warning">
|
||||
<strong>Hinweise zu den hochgeladenen Logos:</strong>
|
||||
<ul class="mb-0">
|
||||
{% for h in logo_hinweise %}<li>{{ h }}</li>{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- ─── Logo-Vorschau (wird nach POST angezeigt) ─── -->
|
||||
<div class="row g-3 mb-4">
|
||||
@@ -121,41 +130,67 @@
|
||||
</div>
|
||||
|
||||
<!-- ─── Eingabeformular (bis zu 3 Kunden) ─── -->
|
||||
<form method="POST">
|
||||
<form method="POST" enctype="multipart/form-data">
|
||||
<input type="hidden" name="site" value="{{ site }}">
|
||||
|
||||
{# Echte Zeilenumbrueche statt - in einer Variablen wuerde Jinja
|
||||
das kaufmaennische Und escapen und die Entity waere als Text sichtbar. #}
|
||||
{% set slots = [
|
||||
("z. B. Beispiel GmbH", "**Max Mustermann**\n+49 123 4567890\n*E-Mail*: max@beispiel.de"),
|
||||
("z. B. Muster AG", "**Maria Schmidt**\n+49 456 7890123\n*E-Mail*: maria@muster.de"),
|
||||
("z. B. Firma GmbH", "**Sabine Weber**\n+49 789 0123456\n*E-Mail*: sabine@firma.de")
|
||||
] %}
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
{% for i in range(3) %}
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="customer_name_1">Kunde 1</label>
|
||||
<input type="text" class="form-control" id="customer_name_1"
|
||||
name="customer_name_1" placeholder="z. B. Beispiel GmbH"
|
||||
value="{{ customer_names[0] if customer_names else '' }}">
|
||||
<label class="form-label mt-2" for="contact_1">Ansprechpartner (optional)</label>
|
||||
<textarea class="form-control" id="contact_1" name="contact_1" rows="4"
|
||||
placeholder="**Max Mustermann** +49 123 4567890 *E-Mail*: max@beispiel.de">{{ contacts[0] if contacts else '' }}</textarea>
|
||||
<label class="form-label" for="customer_name_{{ i + 1 }}">Kunde {{ i + 1 }}{% if i > 0 %} (optional){% endif %}</label>
|
||||
<input type="text" class="form-control" id="customer_name_{{ i + 1 }}"
|
||||
name="customer_name_{{ i + 1 }}" placeholder="{{ slots[i][0] }}"
|
||||
value="{{ customer_names[i] if customer_names else '' }}">
|
||||
|
||||
<label class="form-label mt-2" for="contact_{{ i + 1 }}">Ansprechpartner (optional)</label>
|
||||
<textarea class="form-control" id="contact_{{ i + 1 }}" name="contact_{{ i + 1 }}" rows="4"
|
||||
placeholder="{{ slots[i][1] }}">{{ contacts[i] if contacts else '' }}</textarea>
|
||||
|
||||
<hr class="mt-4 mb-3">
|
||||
|
||||
<label class="form-label" for="logo_file_{{ i + 1 }}">Logo hochladen (optional)</label>
|
||||
<input type="file" class="form-control logo-datei" id="logo_file_{{ i + 1 }}"
|
||||
name="logo_file_{{ i + 1 }}" accept="image/*"
|
||||
data-status="logo_status_{{ i + 1 }}">
|
||||
<div class="form-hint" id="logo_status_{{ i + 1 }}">Keine Datei gewählt</div>
|
||||
<div class="form-check mt-1">
|
||||
<input class="form-check-input" type="checkbox"
|
||||
id="logo_keep_bg_{{ i + 1 }}" name="logo_keep_bg_{{ i + 1 }}">
|
||||
<label class="form-check-label small" for="logo_keep_bg_{{ i + 1 }}">Hintergrund behalten</label>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="customer_name_2">Kunde 2 (optional)</label>
|
||||
<input type="text" class="form-control" id="customer_name_2"
|
||||
name="customer_name_2" placeholder="z. B. Muster AG"
|
||||
value="{{ customer_names[1] if customer_names else '' }}">
|
||||
<label class="form-label mt-2" for="contact_2">Ansprechpartner (optional)</label>
|
||||
<textarea class="form-control" id="contact_2" name="contact_2" rows="4"
|
||||
placeholder="**Maria Schmidt** +49 456 7890123 *E-Mail*: maria@muster.de">{{ contacts[1] if contacts else '' }}</textarea>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="customer_name_3">Kunde 3 (optional)</label>
|
||||
<input type="text" class="form-control" id="customer_name_3"
|
||||
name="customer_name_3" placeholder="z. B. Firma GmbH"
|
||||
value="{{ customer_names[2] if customer_names else '' }}">
|
||||
<label class="form-label mt-2" for="contact_3">Ansprechpartner (optional)</label>
|
||||
<textarea class="form-control" id="contact_3" name="contact_3" rows="4"
|
||||
placeholder="**Sabine Weber** +49 789 0123456 *E-Mail*: sabine@firma.de">{{ contacts[2] if contacts else '' }}</textarea>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="text-muted mb-2">
|
||||
Das System sucht automatisch nach den Kundenlogos und erstellt eine angepasste Willkommensseite.
|
||||
</div>
|
||||
|
||||
<div class="text-muted small mb-3">
|
||||
<i class="ti ti-info-circle me-1"></i>Ein hochgeladenes Logo hat Vorrang vor der automatischen Suche.
|
||||
Der Hintergrund wird automatisch freigestellt, damit das Logo auf der Willkommensseite nicht in einem
|
||||
weissen Kasten steht. Am besten funktioniert das bei einem Logo auf einfarbigem Grund; ein PNG mit
|
||||
bereits transparentem Hintergrund wird unverändert übernommen.
|
||||
</div>
|
||||
|
||||
<hr class="mt-4 mb-3">
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="valid_until">Anzeigen bis <span class="text-muted">(optional)</span></label>
|
||||
<input type="date" class="form-control" id="valid_until" name="valid_until"
|
||||
value="{{ request.form.get('valid_until', '') }}" style="max-width: 260px;">
|
||||
<div class="form-hint">Ohne Angabe bleibt die Willkommensseite stehen, bis sie von Hand entfernt wird. Mit Datum verschwindet sie nach diesem Tag auf allen Screens von selbst.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 text-muted">
|
||||
<p class="mb-1">Das System sucht automatisch nach den Kundenlogos</p>
|
||||
<p class="mb-0">und erstellt eine angepasste Willkommensseite für die Lobby-Playlist.</p>
|
||||
<p class="mb-1">Sie erscheint auf allen Screens dieses Standorts, bei denen im Tab „Einstellungen" die Option <em>Willkommensseite hier anzeigen</em> aktiv ist{% if welcome_screens %} – aktuell: <strong>{{ welcome_screens | join(', ') }}</strong>{% else %} – <strong>aktuell keiner</strong>{% endif %}.</p>
|
||||
<p class="mb-0 small"><i class="ti ti-markdown me-1"></i>Ansprechpartner unterstützen <strong>fett</strong>, <em>kursiv</em>, <code>Code</code>, Aufzählungen (<code>- </code>) sowie Zeilenumbrüche.</p>
|
||||
</div>
|
||||
|
||||
@@ -215,12 +250,26 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="app-footer">
|
||||
{% include "_footer.html" %}
|
||||
</footer>
|
||||
</div> <!-- /page -->
|
||||
|
||||
<script>
|
||||
|
||||
/* Gewaehlte Logo-Datei sichtbar machen. Ein Neuladen der Seite - etwa beim
|
||||
Wechsel des Standorts - leert Datei-Felder, ohne dass man es bemerkt. */
|
||||
document.querySelectorAll(".logo-datei").forEach(function (feld) {
|
||||
var status = document.getElementById(feld.dataset.status);
|
||||
feld.addEventListener("change", function () {
|
||||
if (feld.files && feld.files.length) {
|
||||
var kb = Math.round(feld.files[0].size / 1024);
|
||||
status.textContent = "Gewählt: " + feld.files[0].name + " (" + kb + " KB)";
|
||||
status.className = "form-hint text-success";
|
||||
} else {
|
||||
status.textContent = "Keine Datei gewählt";
|
||||
status.className = "form-hint";
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
<a class="nav-link py-1 px-2 small" href="#help-standort"><i class="ti ti-building-community me-1"></i>Standort</a>
|
||||
<a class="nav-link py-1 px-2 small" href="#help-screen"><i class="ti ti-devices me-1"></i>Screen</a>
|
||||
<a class="nav-link py-1 px-2 small" href="#help-playlist"><i class="ti ti-list me-1"></i>Playlist</a>
|
||||
<a class="nav-link py-1 px-2 small" href="#help-zeitsteuerung"><i class="ti ti-clock me-1"></i>Zeitsteuerung</a>
|
||||
<a class="nav-link py-1 px-2 small" href="#help-priority"><i class="ti ti-player-play me-1"></i>Priority</a>
|
||||
<a class="nav-link py-1 px-2 small" href="#help-actions"><i class="ti ti-external-link me-1"></i>Action Button</a>
|
||||
<a class="nav-link py-1 px-2 small" href="#help-voice"><i class="ti ti-robot me-1"></i>Voice Agent</a>
|
||||
@@ -103,22 +104,80 @@
|
||||
<li><strong>Medien hochladen:</strong> Im Tab "Medien" eine Datei auswählen und hochladen. Unterstützt werden Bilder (JPG, PNG) und Videos (MP4).</li>
|
||||
<li><strong>URL hinzufügen:</strong> Im Tab "Playlist" eine URL eingeben und optional den Zoom-Faktor anpassen.</li>
|
||||
<li><strong>Sortieren:</strong> Per Drag & Drop die Reihenfolge der Playlist-Einträge ändern (Ziehgriff links).</li>
|
||||
<li><strong>Deaktivieren:</strong> Mit der Checkbox einzelne Einträge temporär ausblenden.</li>
|
||||
<li><strong>Löschen:</strong> Über den roten Papierkorb-Button.</li>
|
||||
<li><strong>Deaktivieren:</strong> Mit der Checkbox einzelne Einträge temporär ausblenden. Die Checkbox ist ein reiner Schalter und hat mit der Zeitsteuerung nichts zu tun: Bei einem zeitgesteuerten Eintrag bleibt der Haken gesetzt, auch wenn der Eintrag gerade nicht läuft. Ob er läuft, zeigt das Zeit-Badge – <span class="badge bg-info text-white">Zeit</span> blau = läuft gerade, <span class="badge bg-secondary-lt">Zeit</span> grau = ausserhalb des Zeitfensters. Abgespielt wird nur, was <em>beides</em> erfüllt: Haken gesetzt und im Zeitfenster.</li>
|
||||
<li><strong>Löschen:</strong> Über den roten Papierkorb-Button. Die Datei wird dabei entfernt <em>und</em> der Eintrag aus der Playlist genommen.</li>
|
||||
<li><strong>Bearbeiten:</strong> Der <em>Edit</em>-Button öffnet einen Dialog. Bei URLs lassen sich dort Adresse, Zoom, Query-Parameter-Passthrough und Zeitsteuerung ändern; bei Bildern, Videos und HTML-Seiten die Zeitsteuerung.</li>
|
||||
<li><strong>Zeitsteuerung:</strong> Einträge können nur zu bestimmten Zeiten laufen – siehe <a href="#help-zeitsteuerung">Zeitsteuerung</a>. Ein Badge <span class="badge bg-info text-white">Zeit</span> in der Playlist-Zeile zeigt an, dass ein Eintrag zeitgesteuert ist; der Tooltip nennt die Einstellung im Klartext.</li>
|
||||
<li><strong>Query-Parameter-Passthrough:</strong> Per URL-Edit-Button (Stift-Icon) im Playlist-Eintrag kann pro URL gesteuert werden, ob Query-Parameter von der Player-URL an die Playlist-URL übergeben werden. Standardmäßig <strong>aus</strong> – Checkbox "Query-Parameter an diese URL übergeben" aktivieren wenn gewünscht. Ein Link-Icon in der Playlist-Zeile zeigt den Status: <i class="ti ti-link"></i> = an, <i class="ti ti-link-off"></i> = aus.</li>
|
||||
</ul>
|
||||
<p class="text-muted small mb-0"><i class="ti ti-info-circle me-1"></i>Änderungen an der Playlist werden automatisch gespeichert. Der Player lädt die Seite automatisch neu (via Hash-Prüfung).</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><h3 class="card-title" id="help-zeitsteuerung"><i class="ti ti-clock me-1"></i>Zeitsteuerung</h3></div>
|
||||
<div class="card-body">
|
||||
<p>Jeder Playlist-Eintrag – URL, Bild, Video oder HTML-Seite – kann nur zu bestimmten Zeiten laufen. Das gilt für die Playlist eines Screens genauso wie für die Priority-Playlist.</p>
|
||||
|
||||
<p><strong>Einstellen:</strong> beim Anlegen im Tab "Medien" unter <em>Zeitsteuerung</em>, oder nachträglich über den <em>Edit</em>-Button des Eintrags. Häkchen bei "Zeitgesteuerte Wiedergabe" setzen und einen Modus wählen:</p>
|
||||
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr><th style="width:110px">Modus</th><th>Bedeutung</th><th>Felder</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>Einmalig</strong></td>
|
||||
<td>Läuft einmal in einem zusammenhängenden Zeitraum – auch über mehrere Tage am Stück.</td>
|
||||
<td>Start und Ende jeweils mit Datum <em>und</em> Uhrzeit</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Täglich</strong></td>
|
||||
<td>Läuft jeden Tag im selben Zeitfenster.</td>
|
||||
<td>Von / Bis als Uhrzeit, optional "Gültig von / bis"</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Wöchentlich</strong></td>
|
||||
<td>Wie täglich, aber nur an den angehakten Wochentagen.</td>
|
||||
<td>Von / Bis als Uhrzeit, Wochentage, optional "Gültig von / bis"</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p><strong>Gültig von / bis</strong> ist optional und begrenzt, an welchen <em>Tagen</em> das Zeitfenster überhaupt greift. Beide Grenzen lassen sich einzeln setzen und zählen den genannten Tag mit. Ohne Angabe läuft die Wiedergabe unbefristet.</p>
|
||||
|
||||
<p class="mb-1"><strong>Beispiele:</strong></p>
|
||||
<ul>
|
||||
<li>Täglich, 08:00 bis 12:00, ohne Gültigkeitszeitraum → jeden Vormittag, unbefristet.</li>
|
||||
<li>Täglich, 08:00 bis 12:00, gültig vom 10.09. bis 20.09. → nur an diesen elf Tagen.</li>
|
||||
<li>Wöchentlich, 09:00 bis 17:00, Mo/Mi/Fr → nur an diesen drei Tagen während der Bürozeit.</li>
|
||||
<li>Täglich, 22:00 bis 02:00 → abends bis in die Nacht hinein, über Mitternacht hinweg.</li>
|
||||
</ul>
|
||||
|
||||
<p class="mb-1"><strong>Gut zu wissen:</strong></p>
|
||||
<ul>
|
||||
<li>Bei "Täglich" und "Wöchentlich" zählt nur die <em>Uhrzeit</em> – ein Datum wird ausschliesslich unter "Gültig von / bis" angegeben.</li>
|
||||
<li>Bei "Täglich" gilt automatisch jeder Wochentag; die Auswahl der Wochentage erscheint deshalb nur bei "Wöchentlich".</li>
|
||||
<li>Ein Fenster über Mitternacht gehört zum Tag seines <em>Starts</em>: "Mi 22:00–02:00" läuft Mittwochabend bis Donnerstagfrüh.</li>
|
||||
<li>Die Checkbox links im Playlist-Eintrag wirkt weiterhin unabhängig davon – ein deaktivierter Eintrag läuft auch innerhalb seines Zeitfensters nicht.</li>
|
||||
<li>Der Player merkt von selbst, wenn ein Zeitfenster beginnt oder endet, und lädt neu. Ein manueller Eingriff am Screen ist nicht nötig.</li>
|
||||
</ul>
|
||||
|
||||
<p class="text-muted small mb-0"><i class="ti ti-info-circle me-1"></i>Es gilt die Uhrzeit des Servers, nicht die des Players.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><h3 class="card-title" id="help-priority"><i class="ti ti-player-play me-1"></i>Priority-Playlist</h3></div>
|
||||
<div class="card-body">
|
||||
<p>Die Priority-Playlist ist <strong>global</strong> und wird auf <em>allen</em> Playern eines Standorts eingeblendet – zwischen den normalen Playlist-Einträgen.</p>
|
||||
<p>Die Priority-Playlist gilt <strong>pro Standort</strong>. Ihre Inhalte werden auf <em>allen</em> Screens dieses Standorts eingeblendet – zwischen den normalen Playlist-Einträgen.</p>
|
||||
<ul>
|
||||
<li>Aufruf über <code>/admin/<standort>/priority</code></li>
|
||||
<li>Globale Medien und URLs verwalten, ebenfalls per Drag & Drop</li>
|
||||
<li>Priority muss in der Konfiguration aktiviert sein (<code>config.json → priority.enabled</code>)</li>
|
||||
<li>Medien und URLs dieses Standorts verwalten, ebenfalls per Drag & Drop</li>
|
||||
<li>Priority muss für den Standort aktiviert sein (<code>config.json → sites.<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>Jeder Standort hat seine <strong>eigene</strong> Priority-Playlist – Inhalte eines Standorts erscheinen nie auf den Screens eines anderen.</li>
|
||||
<li><strong>Vorschau in den Screen-Playlisten:</strong> Die Priority-Einträge werden zusätzlich oberhalb jeder normalen Playlist angezeigt – ausgegraut und mit rotem <span class="badge bg-red text-red-fg">Prio</span>-Badge. Das ist eine <em>reine Anzeige</em>: Sie lässt sich dort nicht sortieren, bearbeiten oder löschen. So ist beim Bearbeiten einer Screen-Playlist sofort sichtbar, was dem Screen ohnehin noch vorweg läuft, ohne dass man in den anderen Menüpunkt wechseln muss. Geändert wird weiterhin ausschliesslich auf der Priority-Seite.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -190,9 +249,16 @@
|
||||
<div class="card-header"><h3 class="card-title" id="help-willkommen"><i class="ti ti-users me-1"></i>Willkommensseite</h3></div>
|
||||
<div class="card-body">
|
||||
<p>Auf der Willkommensseite können bis zu 3 Kundennamen eingegeben werden. Die Logos werden automatisch via OpenAI + Brandfetch gesucht.</p>
|
||||
<p class="text-muted small"><i class="ti ti-lock-open me-1"></i><strong>Ohne Anmeldung nutzbar:</strong> Das Formular kann auch ohne Benutzerkonto aufgerufen werden – etwa am Empfang. Wer angemeldet ist, sieht weiterhin nur die eigenen Standorte; ohne Anmeldung stehen alle zur Auswahl. Im Aktivitätsverlauf erscheinen solche Einträge als „nicht angemeldet".</p>
|
||||
<ul>
|
||||
<li>Aufruf über <code>/willkommen?site=<standort></code> (oder Menüpunkt)</li>
|
||||
<li>Die generierte <code>welcome.html</code> wird automatisch an den Anfang der Lobby-Playlist gesetzt</li>
|
||||
<li><strong>Zielscreens:</strong> Die generierte <code>welcome.html</code> wird automatisch an den Anfang der Playlist <em>jedes</em> Screens gesetzt, bei dem im Tab "Einstellungen" die Option <em>Willkommensseite hier anzeigen</em> aktiv ist. So lässt sich dieselbe Begrüßung auf mehreren Screens zeigen, ohne sie mehrfach anzulegen. Ist an einem Standort nichts eingestellt, geht sie wie bisher in die Lobby.</li>
|
||||
<li><strong>Der Schalter wirkt sofort:</strong> Schaltest du ihn bei einem weiteren Screen ein, erscheint eine bereits erzeugte Willkommensseite dort umgehend in der Playlist – ohne dass du sie neu generieren musst. Schaltest du ihn aus, verschwindet sie dort wieder.</li>
|
||||
<li><strong>Anzeigen bis:</strong> Optionales Datum im Formular. Ist es gesetzt, verschwindet die Willkommensseite nach diesem Tag auf allen Screens von selbst – kein manuelles Aufräumen nach dem Besuch. Ohne Angabe bleibt sie stehen.</li>
|
||||
<li><strong>Logo hochladen:</strong> Findet die automatische Suche kein Logo oder passt es nicht, kann pro Kunde eine eigene Bilddatei hochgeladen werden. Ein Upload hat immer Vorrang vor der automatischen Suche.</li>
|
||||
<li><strong>Freistellen:</strong> Der Hintergrund eines hochgeladenen Logos wird automatisch entfernt, damit es auf der Willkommensseite nicht in einem weissen Kasten steht. Das gelingt am besten bei einem Logo auf einfarbigem Grund – ein PNG mit bereits transparentem Hintergrund wird unverändert übernommen. Bei unruhigem Bildrand – oder wenn das Logo farblich zu nah am Hintergrund liegt und beim Freistellen verschwinden würde – bleibt das Bild unangetastet und es erscheint ein Hinweis. Mit „Hintergrund behalten" lässt sich das Freistellen abschalten.</li>
|
||||
<li><strong>Grösse:</strong> Nach dem Freistellen werden leere Ränder weggeschnitten, damit das hochgeladene Logo auf der Willkommensseite genauso gross erscheint wie ein automatisch gefundenes.</li>
|
||||
<li>Ein einmal hochgeladenes Logo bleibt beim erneuten Generieren erhalten, solange der Kundenname im selben Feld steht.</li>
|
||||
<li>Pro Standort kann ein eigenes Hintergrundbild hochgeladen werden (Tab "Medien" im Standort-Dashboard)</li>
|
||||
<li>Pro Kunde können <strong>Ansprechpartner</strong> eingegeben werden (Markdown-Format):
|
||||
<ul>
|
||||
@@ -244,6 +310,7 @@
|
||||
<li><strong>Sparkline:</strong> Kleine Liniendiagramme zeigen den Verlauf der letzten Änderungen</li>
|
||||
<li><strong>Trend:</strong> Pfeil nach oben/unten mit Netto-Änderung aus den letzten 30 Aktionen</li>
|
||||
<li><strong>Aktivitätsverlauf:</strong> Alle Erstell-, Lösch-, Änderungsaktionen sowie An-/Abmeldungen werden protokolliert</li>
|
||||
<li><strong>Blättern:</strong> Der Verlauf zeigt 50 Einträge je Seite; über die Leiste unter der Liste lässt sich weiter zurückblättern. Aufbewahrt werden die letzten 1000 Einträge. Statistik-Cards, Sparklines und Trendpfeile beziehen sich dabei immer auf die jüngsten 30 Aktionen und ändern sich beim Blättern nicht.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
<input class="form-check-input" type="checkbox" id="remember" name="remember" value="7">
|
||||
<label class="form-check-label" for="remember">
|
||||
Gerät für
|
||||
<select class="form-select form-select-sm d-inline-block w-auto" id="remember_days" onchange="document.getElementById('remember').value=this.value">
|
||||
<select class="form-select form-select-sm d-inline-block w-auto" id="remember_days">
|
||||
<option value="1">1 Tag</option>
|
||||
<option value="3">3 Tage</option>
|
||||
<option value="7" selected>7 Tage</option>
|
||||
@@ -61,10 +61,13 @@
|
||||
var cb = document.getElementById('remember');
|
||||
var sel = document.getElementById('remember_days');
|
||||
cb.addEventListener('change', function() {
|
||||
if (this.checked) this.value = sel.value;
|
||||
this.value = sel.value;
|
||||
});
|
||||
// Wer im Dropdown eine Dauer waehlt, will das Geraet merken:
|
||||
// Checkbox automatisch mit aktivieren.
|
||||
sel.addEventListener('change', function() {
|
||||
if (cb.checked) cb.value = this.value;
|
||||
cb.checked = true;
|
||||
cb.value = this.value;
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -472,7 +472,8 @@ function playNext() {
|
||||
}
|
||||
|
||||
// Datei aus dem Medienverzeichnis
|
||||
const src = entry.isPrio ? `/media/priority/${item.name}` : `/media/${site}/${basePath}/${item.name}`;
|
||||
// ?v=<mtime> erlaubt dem Browser, die Datei ohne Rueckfrage aus dem Cache zu nehmen
|
||||
const src = `/media/${site}/${basePath}/${item.name}` + (item.v ? `?v=${item.v}` : "");
|
||||
if (isVideo(item)) {
|
||||
vid.style.display = "block";
|
||||
vid.src = src;
|
||||
|
||||
@@ -147,13 +147,13 @@
|
||||
{% if file.type == "image" %}
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-html="true"
|
||||
title="<img src='/media/priority/{{ file.name }}' style='max-width: 200px; max-height: 150px; border: none; background: white; padding: 2px;' alt='Vorschau'>"
|
||||
title="<img src='/media/{{ current_site }}/priority/{{ file.name }}' style='max-width: 200px; max-height: 150px; border: none; background: white; padding: 2px;' alt='Vorschau'>"
|
||||
{% elif file.type == "url" %}
|
||||
data-bs-toggle="tooltip"
|
||||
title="URL: {{ file.name }}{% if file.zoom %} (Zoom: {{ file.zoom }}x){% endif %}"
|
||||
{% endif %}>
|
||||
{{ file.name }}{% if file.type == "url" and file.zoom != 1.0 %} <strong style="color: #DA002D;">[{{ file.zoom }}x]</strong>{% endif %}{% if file.type == "url" %}{% if file.get('passthrough', False) %} <i class="ti ti-link" style="color:#6c757d;" title="Query-Parameter werden übergeben"></i>{% else %} <i class="ti ti-link-off" style="color:#adb5bd;" title="Query-Parameter werden nicht übergeben"></i>{% endif %}{% endif %}
|
||||
{% if file.schedule %}<span class="badge bg-info text-white ms-1" title="{{ file.schedule | schedule_text }}">Zeit</span>{% endif %}
|
||||
{% if file.schedule %}<span class="badge ms-1 {% if file.active %}bg-info text-white{% else %}bg-secondary-lt{% endif %}" title="{{ file.schedule | schedule_text }}{% if not file.active %} — laeuft gerade nicht{% endif %}">Zeit</span>{% endif %}
|
||||
</span>
|
||||
|
||||
{% if file.type == "image" %}
|
||||
|
||||
16
users.json
16
users.json
@@ -19,7 +19,6 @@
|
||||
"trusted_devices": []
|
||||
},
|
||||
"erik.thiele@cancom.de": {
|
||||
"password_hash": "scrypt:32768:8:1$D8XYprsEYMO5EPrG$2829b18f77bbd7d9f0cbb945b0bdbc32e16d7889f8711881f82dd35accd26ddb7c31d3fa85e3b89d19d6d1f0b92d8d18765e1c82044188cab8fd0db2d35a0d15",
|
||||
"role": "admin",
|
||||
"sites": [],
|
||||
"must_change_password": false,
|
||||
@@ -27,24 +26,13 @@
|
||||
"last_name": "Thiele",
|
||||
"department": "CSO",
|
||||
"notes": "Standort Stuttgart",
|
||||
"mfa_secret": "LR5DYI3SQPMW5XWKO33HPETHY5TPOWRG",
|
||||
"mfa_enabled": true,
|
||||
"mfa_recovery_codes": [
|
||||
"7BBD93",
|
||||
"EB1D3B",
|
||||
"BEE291",
|
||||
"D471D5",
|
||||
"D43C00",
|
||||
"75DBAC",
|
||||
"8B00CB",
|
||||
"7AF72D"
|
||||
],
|
||||
"trusted_devices": [
|
||||
{
|
||||
"token": "5kMgj4JPxd2IHuzV5MagAAcYyi_DBImyGimBSvZUNVg",
|
||||
"expires": "2026-07-24T15:40:28.955130"
|
||||
}
|
||||
]
|
||||
],
|
||||
"password_hash": "scrypt:32768:8:1$9k9WAwHOnYTdKY4Q$869e9bec6a838c212543a228edc7a516933b37b9494bb1d10424f18adef3ea132d4c340ab939ca023c22ab0d4def13ad64c44a648f64ce519c2097adc0537f92"
|
||||
},
|
||||
"test1@test.de": {
|
||||
"password_hash": "scrypt:32768:8:1$35kBhL79TJyVA3Rz$98b2e269db5ae96c9285323a274a0cc5b199fb45078e7530b5e72cdb9b96d5b522aa18fd06ce0f801bd19ec68ed323ffa04ce484cfb849eba73b9260ca6a50e1",
|
||||
|
||||
Reference in New Issue
Block a user