Files
signage/AGENTS.md
Erik Thiele 317f2a6eb2 Doku: README, AGENTS und Hilfeseite aktualisiert
- README: Willkommensseite-Sektion mit Ansprechpartner/Markdown/Layout
- AGENTS: generate_welcome_html Signatur + Verhalten dokumentiert
- help.html: Ansprechpartner-Felder, Markdown-Hilfe, Portrait-Layout
2026-07-04 10:34:11 +02:00

217 lines
14 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CANCOM Simple Signage — Agent Guide
## Start
```bash
pip install -r requirements.txt
python app.py
docker compose up -d
```
`python app.py` (via `.venv/bin/python app.py`) serves on `http://localhost:5005`. `app.py` runs Flask with `debug=True`, `host="0.0.0.0"`, and `port=5005`.
## Source Of Truth
- Single Flask app in `app.py`; there is no database.
- Persistent state is `config.json` plus files under `media/<site>/<screen>/`.
- User data in `users.json` (hashed passwords via werkzeug.security scrypt).
- Port 5005; `README.md` hat den korrekten Port.
- Existing repo instructions in this file are the main local guidance; there is no `opencode.json` or workflow config in this repo.
## Multi-Standort-URL-Struktur
Standorte (sites) sind die oberste Organisationsebene und gruppieren Screens.
| Route | Beschreibung |
|-------|-------------|
| `GET /player/<site>/<screen>` | Player für Screen an einem Standort |
| `GET /admin` | Redirect zum ersten zugänglichen Standort |
| `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>/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 |
| `POST /admin/<site>/upload-background` | Hintergrundbild für Willkommensseite hochladen |
| `POST /admin/<site>/delete-background` | Hintergrundbild zurücksetzen auf Standard |
| `POST /api/customer` | API-Endpunkt (JSON mit "site"-Feld) |
| `GET /admin/<site>/add-screen?name=<name>` | Neuen Screen anlegen |
| `POST /admin/<site>/delete-screen/<screen>` | Screen + Medien löschen |
| `GET /add-site?name=<name>` | Neuen Standort anlegen (nur Admin) |
| `POST /admin/<site>/update-actions/<screen>` | Aktionen-Einstellungen speichern (Custom-URL + Position) |
| `POST /admin/<site>/update-voice/<screen>` | Voice-Agent-Einstellungen speichern (Enabled, Label, Target, Position) |
| `POST /admin/<site>/delete-site` | Standort + alle Screens/Medien löschen (nur Admin) |
| `GET /change-password` | Passwort ändern-Seite |
| `GET /admin/users` | User-Liste (nur Admin) |
| `GET /admin/users/create` | User anlegen (nur Admin) |
| `GET /admin/users/edit/<email>` | User bearbeiten (nur Admin) |
## User-Verwaltung (seit v5.6.0)
- Login via E-Mail + Passwort (scrypt-gehasht in `users.json`)
- Drei Rollen:
- **Admin**: Zugriff auf alle Standorte + Userverwaltung (anlegen/bearbeiten/löschen/Reset)
- **Superuser**: Zugriff auf alle Standorte, keine Userverwaltung
- **User**: Zugriff nur auf zugewiesene Standorte
- Erster Login erfordert Passwort-Änderung (`must_change_password=True`)
- Admin-Reset generiert temporäres Passwort (wird im UI angezeigt)
- Fehlender/leerer `password_hash` → Login ohne Prüfung, direkt zu `/change-password`
- Letzter Admin kann nicht gelöscht werden
- `users.json` wird beim ersten Start aus `config.json.admin` befüllt
- Notfall: `users.json` löschen → Server-Neustart erzeugt neuen Admin
### User-Routen
| Route | Beschreibung |
|-------|-------------|
| `GET /admin/users` | User-Liste (Admin, mit MFA-Spalte) |
| `GET /admin/users/create` | User anlegen (Formular) |
| `POST /admin/users/create` | User anlegen |
| `GET /admin/users/edit/<email>` | User bearbeiten (Formular) |
| `POST /admin/users/edit/<email>` | User bearbeiten speichern |
| `POST /admin/users/delete/<email>` | User löschen (letzter Admin geschützt) |
| `POST /admin/users/reset-password/<email>` | Passwort-Reset (temporäres Passwort) |
| `GET /mfa/setup` | MFA einrichten (QR-Code + Verifikation) |
| `POST /mfa/setup` | TOTP-Code verifizieren → MFA aktivieren |
| `GET /mfa/verify` | TOTP-Code nach Login eingeben |
| `POST /mfa/verify` | Code prüfen oder Recovery-Code verwenden |
| `POST /mfa/disable` | MFA deaktivieren (mit Passwort) |
### MFA (Multi-Faktor-Authentifizierung)
- TOTP via `pyotp`, QR-Code via `qrcode[pil]` (nur in `.venv` installiert)
- Der TOTP-Secret wird sofort beim ersten Aufruf von `/mfa/setup` (GET) in `users.json` persistiert, sodass die nachfolgende POST-Verifikation denselben Secret verwendet
- Nach erfolgreicher Aktivierung: 8 Recovery-Codes (hex, 7 Stellen), einmalig im UI angezeigt, jeder nur einmal verwendbar
- Recovery-Code-Einsatz erzwingt erneute MFA-Einrichtung
- Deaktivierung via `/mfa/disable` mit Passwort-Bestätigung
- MFA-Status in der Userliste (`/admin/users`) als grünes "Aktiv"-Badge oder ""
### MFA-Login-Flow
1. POST `/login` mit Passwort → bei aktivem MFA: Session `mfa_pending` setzen → redirect `/mfa/verify`
2. GET/POST `/mfa/verify` → TOTP-Code prüfen → `login_user()` aufrufen → redirect `/admin/<site>`
3. Recovery-Code: POST `/mfa/verify` mit `recovery=true` → Code prüfen → `mfa_enabled=False` → Login erlauben, aber User muss MFA neu einrichten
### Zugriffs-Dekoratoren
| Dekorator | Wirkung |
|-----------|---------|
| `@login_required` | User muss eingeloggt sein |
| `@admin_required` | Nur Admins |
| `@site_access_required` | Admin/Superuser → alle Sites, User → nur freigegebene |
## Behavior To Preserve
- `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.
- `.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.
- 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.
- 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_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.
- Voice-Agent-Button: globale `config.voice_agent_url` wird pro Screen im Tab "Digital Voice Agent" konfiguriert; Einstellungen (`voice_agent_enabled`, `voice_agent_label`, `voice_agent_target`, `voice_agent_position`) funktionieren identisch zum Aktionen-Tab. Die URL ist read-only im Admin sichtbar und wird global in `config.json` gesetzt.
- Voice-Agent-Typewriter: Tagline im Button zeigt wechselnde mehrsprachige Texte mit Buchstaben-für-Buchstaben-Effekt; nach 2,5s Pause fade-out über 0,8s, dann nächster Text.
- Voice-Agent-Overlay: positioniert den Zurück-Button exakt an der Position des geklickten Buttons (identisches Positionssystem).
- `voice_agent_show_image` (per Screen): optionales Bild (`static/dva.png`) über dem Button, per Admin-Toggle schaltbar (Beta).
- Action-Button (custom_url) und Voice-Agent-Button haben einheitliches Styling: weißer Hintergrund, schwarzer Text, 20px Border-Radius, fette Schrift.
- Neue Standorte können über den `+`-Button im Header oder über `GET /add-site?name=<name>` angelegt werden (nur Admins).
- `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.
- 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.
- Player-URL im Screen-Header ist ein klickbarer Link in grauer Farbe.
- `add_customer` und `/customer` erfordern jetzt Login (`@login_required`) mit Site-Zugriffsprüfung.
- `/admin` redirectet zum ersten für den User zugänglichen Standort (nicht mehr global ersten).
- 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
## 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()`.
- 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.
- `generate_welcome_html(customer_names, logo_urls, contacts=None, site="stuttgart", background_url=None)` akzeptiert optionale `contacts`-, `site`- und `background_url`-Parameter.
- `welcome.html` wird in `media/<site>/lobby/` gespeichert.
- `search_customer_logo` in `generate_welcome_page.py` nutzt OpenAI GPT-4 + Brandfetch CDN.
- `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).
## Heartbeat / Online-Status (seit v6.1.0)
Der Player-Status (Online/Offline) wird mittels dateibasiertem Heartbeat ermittelt.
### Funktionsweise
| Schritt | Beschreibung |
|---------|-------------|
| **Player-pollt** alle 5s `/playlist/<site>/<screen>/hash` (`checkForUpdates`) | Bestehender Mechanismus, kein Extra-Request |
| **Server zeichnet Heartbeat auf** | `record_heartbeat(site, screen)` erstellt/updated `/tmp/signage-heartbeat/<site>/<screen>` via `path.touch()` |
| **Admin-UI fragt Status ab** | `screen_is_active(site, screen)` prüft ob `mtime < 60s` alt |
| **Anzeige** | Grüner "Online"-Button (filled success) oder roter "Offline"-Button (filled danger) im Card-Header neben "Löschen" |
| **Client-Info** | Beim Hovern über den Online-Button: Tooltip mit Browser, Auflösung, IP, letztem Seen-Zeitpunkt |
### Helper-Funktionen in `app.py`
```python
HEARTBEAT_DIR = Path("/tmp/signage-heartbeat")
HEARTBEAT_TIMEOUT = 60 # Sekunden
def record_heartbeat(site, screen, ip="", ua="", resolution=""):
"""Schreibt JSON mit Client-Info (ip, ua, resolution, last_seen)."""
path = HEARTBEAT_DIR / site / f"{screen}.json"
path.parent.mkdir(parents=True, exist_ok=True)
data = {"ip": ip, "ua": ua[:200], "resolution": resolution,
"last_seen": datetime.now().strftime("%d.%m.%Y %H:%M:%S")}
path.write_text(json.dumps(data))
def screen_is_active(site, screen):
"""Prüft, ob Heartbeat < 60s alt ist (anhand .json-mtime)."""
path = HEARTBEAT_DIR / site / f"{screen}.json"
if not path.exists():
return False
return (time.time() - path.stat().st_mtime) < 60
def get_client_info(site, screen):
"""Gibt das gespeicherte Client-Info-JSON zurück."""
...
```
### Integration
- `playlist_hash()`-Route liest Query-Parameter `w` (Breite) und `h` (Höhe) + `remote_addr` + `User-Agent` und übergibt sie an `record_heartbeat()`
- `admin()`-Route sammelt `client_info[screen] = get_client_info(...)` für jeden Screen
- Admin-Template (`admin.html`) zeigt den Online-Button mit `title`-Tooltip (Browser, Auflösung, IP, letztes Update) und den Info-Tab mit Client-Info-Tabelle
- Player (`player.html`) sendet `screen.width` und `screen.height` als `?w=...&h=...` im `checkForUpdates`-Fetch
- Die Lösch-Buttons (`btn-outline-danger btn-sm`) haben gleiche Größe durch gleiche btn-sm-Klasse, Form-Wrapper hat `m-0 p-0`
### Docker-Hinweis
Im Docker-Container liegt der Heartbeat-Pfad unter `/tmp/signage-heartbeat/` im Container. Da die Docker-Umgebung keine Volumes mounted, sind Heartbeats container-lokal. Nach `docker compose restart` werden alle Screens kurz rot, bis die Player innerhalb von max. 60s wieder pollen.
### Debugging
```bash
# Alle Heartbeat-Dateien anzeigen
ls -la /tmp/signage-heartbeat/*/
find /tmp/signage-heartbeat -type f -exec ls -la {} \;
```