# 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 {} \;
```
