feat: Override, Wochenend-Sperre und per-Screen-Override implementiert
This commit is contained in:
commit
f5d8311204
13 changed files with 879 additions and 52 deletions
|
|
@ -587,6 +587,93 @@ Der Scheduler prüft jede Minute, ob die aktuelle Uhrzeit mit `power_on_time` od
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Globaler Override
|
||||||
|
|
||||||
|
| Methode | Pfad | Auth | Beschreibung |
|
||||||
|
|---------|------|------|--------------|
|
||||||
|
| GET | `/api/v1/global-override` | authUser | Aktiven Override abrufen (204 wenn keiner aktiv) |
|
||||||
|
| POST | `/api/v1/global-override` | authUser | Override setzen + sofort MQTT an alle Screens |
|
||||||
|
| DELETE | `/api/v1/global-override` | authUser | Override aufheben |
|
||||||
|
|
||||||
|
**GET /api/v1/global-override**
|
||||||
|
|
||||||
|
Ruft den aktuell aktiven globalen Override ab.
|
||||||
|
|
||||||
|
**Response:** `200 OK` (wenn aktiv)
|
||||||
|
```json
|
||||||
|
{"type":"off","until":"2026-04-05T18:00:00+02:00","set_at":"2026-03-27T15:30:00+02:00"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:** `204 No Content` (wenn kein Override aktiv)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**POST /api/v1/global-override**
|
||||||
|
|
||||||
|
Setzt einen globalen Override und sendet sofort MQTT-Befehle an alle Screens.
|
||||||
|
|
||||||
|
**Request-Body:**
|
||||||
|
```json
|
||||||
|
{"type":"off","until":"2026-04-05T18:00:00+02:00"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:** `200 OK`
|
||||||
|
```json
|
||||||
|
{"type":"off","until":"2026-04-05T18:00:00+02:00","set_at":"2026-03-27T15:30:00+02:00"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fehler:**
|
||||||
|
- `400 Bad Request` — `type` nicht "on"/"off", oder ungültiges Zeitformat
|
||||||
|
- `500 Internal Server Error` — DB-Fehler
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**DELETE /api/v1/global-override**
|
||||||
|
|
||||||
|
Hebt den aktuellen globalen Override auf.
|
||||||
|
|
||||||
|
**Response:** `204 No Content`
|
||||||
|
|
||||||
|
**Fehler:**
|
||||||
|
- `500 Internal Server Error` — DB-Fehler
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Per-Screen Override
|
||||||
|
|
||||||
|
| Methode | Pfad | Auth | Beschreibung |
|
||||||
|
|---------|------|------|--------------|
|
||||||
|
| POST | `/api/v1/screens/{screenSlug}/override` | authScreen | Per-Screen "Einschalten bis" setzen oder löschen |
|
||||||
|
|
||||||
|
**POST /api/v1/screens/{screenSlug}/override**
|
||||||
|
|
||||||
|
Setzt oder löscht den per-Screen "Einschalten bis"-Override. Mit diesem Override bleibt ein Monitor bis zu
|
||||||
|
dem angegebenen Zeitpunkt eingeschaltet, selbst wenn der globale Schedule "aus" vorsieht.
|
||||||
|
|
||||||
|
**Auth:** Erforderlich. Screen-Zugriff erforderlich.
|
||||||
|
|
||||||
|
**Path-Parameter:**
|
||||||
|
- `screenSlug` — Slug des Screens
|
||||||
|
|
||||||
|
**Request-Body:**
|
||||||
|
```json
|
||||||
|
{"on_until":"2026-04-05T18:00:00+02:00"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Um den Override zu löschen, `on_until` auf `null` setzen:
|
||||||
|
```json
|
||||||
|
{"on_until":null}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:** `204 No Content`
|
||||||
|
|
||||||
|
**Fehler:**
|
||||||
|
- `400 Bad Request` — Ungültiges Zeitformat oder ungültiges JSON
|
||||||
|
- `404 Not Found` — Screen nicht vorhanden
|
||||||
|
- `500 Internal Server Error` — DB-Fehler
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Message Wall
|
## Message Wall
|
||||||
|
|
||||||
### POST /api/v1/tools/message-wall/resolve
|
### POST /api/v1/tools/message-wall/resolve
|
||||||
|
|
|
||||||
|
|
@ -523,6 +523,36 @@ Regeln:
|
||||||
- `schedule_enabled = false` bedeutet: Zeitplan vorhanden, aber deaktiviert
|
- `schedule_enabled = false` bedeutet: Zeitplan vorhanden, aber deaktiviert
|
||||||
- Leere Zeitfelder bedeuten: kein Einschalt- bzw. kein Ausschaltbefehl
|
- Leere Zeitfelder bedeuten: kein Einschalt- bzw. kein Ausschaltbefehl
|
||||||
|
|
||||||
|
Neue Spalte in `screen_schedules` (Migration `006`):
|
||||||
|
- `override_on_until timestamptz` — Einschalten-Override: Monitor bleibt bis zu diesem Zeitpunkt eingeschaltet (null = kein Override)
|
||||||
|
|
||||||
|
### `global_override` (Migration 006)
|
||||||
|
|
||||||
|
Zweck:
|
||||||
|
|
||||||
|
- Speichert den globalen Display-Override (maximal eine Zeile)
|
||||||
|
|
||||||
|
Spalten:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
id INT PRIMARY KEY DEFAULT 1
|
||||||
|
type TEXT NOT NULL -- "on" oder "off"
|
||||||
|
until TIMESTAMPTZ NOT NULL -- Override aktiv bis zu diesem Zeitpunkt
|
||||||
|
set_at TIMESTAMPTZ NOT NULL DEFAULT now() -- Wann der Override gesetzt wurde
|
||||||
|
```
|
||||||
|
|
||||||
|
Constraint:
|
||||||
|
```sql
|
||||||
|
CHECK (id = 1)
|
||||||
|
```
|
||||||
|
|
||||||
|
Regeln:
|
||||||
|
|
||||||
|
- Die Tabelle enthaelt maximal eine Zeile (id = 1)
|
||||||
|
- `type` bestimmt den globalen Zielzustand (alle Screens)
|
||||||
|
- `until` gibt an, wann der Override automatisch aufgehoben wird
|
||||||
|
- Der Scheduler prueft jede Minute, ob der Override noch aktiv ist (aktuell <= until)
|
||||||
|
|
||||||
### `screen_snapshots`
|
### `screen_snapshots`
|
||||||
|
|
||||||
Zweck:
|
Zweck:
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ Dieses Verzeichnis enthaelt das zentrale Go-Backend fuer das Info-Board-System.
|
||||||
- `internal/app/` — App-Initialisierung und Lifecycle
|
- `internal/app/` — App-Initialisierung und Lifecycle
|
||||||
- `internal/config/` — Konfiguration via Umgebungsvariablen
|
- `internal/config/` — Konfiguration via Umgebungsvariablen
|
||||||
- `internal/db/` — PostgreSQL-Anbindung und Migrations-Runner
|
- `internal/db/` — PostgreSQL-Anbindung und Migrations-Runner
|
||||||
- `internal/store/` — Datenbankzugriff (TenantStore, ScreenStore, MediaStore, PlaylistStore, AuthStore)
|
- `internal/store/` — Datenbankzugriff (TenantStore, ScreenStore, MediaStore, PlaylistStore, AuthStore, ScreenScheduleStore, GlobalOverrideStore)
|
||||||
- `internal/fileutil/` — Upload-Hilfsfunktionen (SaveUploadedFile mit Tenant-Isolation)
|
- `internal/fileutil/` — Upload-Hilfsfunktionen (SaveUploadedFile mit Tenant-Isolation)
|
||||||
- `internal/httpapi/` — HTTP-Routing, Middleware und Handler
|
- `internal/httpapi/` — HTTP-Routing, Middleware und Handler
|
||||||
- `internal/httpapi/csrf.go` — Double-Submit-Cookie CSRF-Schutz
|
- `internal/httpapi/csrf.go` — Double-Submit-Cookie CSRF-Schutz
|
||||||
|
|
@ -42,6 +42,10 @@ Uhrzeit übereinstimmt — per MQTT den Befehl `display_on` bzw. `display_off` s
|
||||||
Der Scheduler wird in `internal/app/app.go` als Goroutine gestartet und laeuft bis zum
|
Der Scheduler wird in `internal/app/app.go` als Goroutine gestartet und laeuft bis zum
|
||||||
Kontext-Abbruch beim Server-Shutdown.
|
Kontext-Abbruch beim Server-Shutdown.
|
||||||
|
|
||||||
|
**Wochenend-Sperre:** An Samstagen und Sonntagen werden Zeitplaene ignoriert — der Reconciler
|
||||||
|
sendet dann keine automatischen Ein-/Ausschalt-Kommandos. Manuelle Overrides (global oder
|
||||||
|
per-Screen) wirken jedoch auch am Wochenende.
|
||||||
|
|
||||||
## Datenbank-Stores
|
## Datenbank-Stores
|
||||||
|
|
||||||
### AuthStore (`internal/store/auth.go`)
|
### AuthStore (`internal/store/auth.go`)
|
||||||
|
|
@ -60,6 +64,16 @@ Kontext-Abbruch beim Server-Shutdown.
|
||||||
- `EnsureAdminUser(ctx, tenantSlug, password)` — Admin-User beim Start anlegen
|
- `EnsureAdminUser(ctx, tenantSlug, password)` — Admin-User beim Start anlegen
|
||||||
- `VerifyPassword(ctx, userID, password)` — Passwort gegen bcrypt-Hash pruefen
|
- `VerifyPassword(ctx, userID, password)` — Passwort gegen bcrypt-Hash pruefen
|
||||||
|
|
||||||
|
### GlobalOverrideStore (`internal/store/store.go`)
|
||||||
|
|
||||||
|
Verwaltet einen systemweiten Display-Override (max. 1 Zeile in `global_override`):
|
||||||
|
|
||||||
|
- `Get(ctx)` — aktuellen globalen Override laden (nil wenn keiner gesetzt)
|
||||||
|
- `Upsert(ctx, type, until)` — Override setzen oder ueberschreiben (`type`: `"on"` | `"off"`)
|
||||||
|
- `Delete(ctx)` — Override entfernen
|
||||||
|
|
||||||
|
Der Reconciler im Scheduler wertet den globalen Override aus und wendet ihn auf alle Screens an.
|
||||||
|
|
||||||
### ScreenStore (`internal/store/screen.go`)
|
### ScreenStore (`internal/store/screen.go`)
|
||||||
|
|
||||||
**Screen-User Zugriffskontrolle:**
|
**Screen-User Zugriffskontrolle:**
|
||||||
|
|
@ -117,6 +131,10 @@ Kontext-Abbruch beim Server-Shutdown.
|
||||||
| GET | `/api/v1/screens/{screenId}/screenshot` | Screenshot eines Screens abrufen |
|
| GET | `/api/v1/screens/{screenId}/screenshot` | Screenshot eines Screens abrufen |
|
||||||
| POST | `/api/v1/screens/{screenSlug}/display` | Display ein-/ausschalten (MQTT) |
|
| POST | `/api/v1/screens/{screenSlug}/display` | Display ein-/ausschalten (MQTT) |
|
||||||
| POST | `/api/v1/screens/{screenSlug}/schedule` | Display-Zeitplan speichern |
|
| POST | `/api/v1/screens/{screenSlug}/schedule` | Display-Zeitplan speichern |
|
||||||
|
| GET | `/api/v1/global-override` | Globalen Override abrufen (204 = kein aktiver Override) |
|
||||||
|
| POST | `/api/v1/global-override` | Globalen Override setzen (type + until); sendet sofort MQTT |
|
||||||
|
| DELETE | `/api/v1/global-override` | Globalen Override loeschen |
|
||||||
|
| POST | `/api/v1/screens/{screenSlug}/override` | Per-Screen-Override setzen oder loeschen (on_until: null = loeschen) |
|
||||||
|
|
||||||
### Nur Admins (`RequireAuth` + `RequireAdmin`)
|
### Nur Admins (`RequireAuth` + `RequireAdmin`)
|
||||||
|
|
||||||
|
|
@ -182,8 +200,9 @@ Middleware zur rollenbasierten Zugriffskontrolle auf Screen-Ressourcen.
|
||||||
|
|
||||||
## Migrationen
|
## Migrationen
|
||||||
|
|
||||||
- `001_core.sql` — initiales Schema (Tenants, Screens, Playlists, Media, etc.)
|
- `001_initial.sql` — initiales Schema (Tenants, Screens, Playlists, Media, etc.)
|
||||||
- `002_auth.sql` — Auth-Tabellen (`users`, `sessions`)
|
- `002_auth.sql` — Auth-Tabellen (`users`, `sessions`)
|
||||||
- `003_user_screen_permissions.sql` — Screen-User Management (`user_screen_permissions`)
|
- `003_user_screen_permissions.sql` — Screen-User Management (`user_screen_permissions`)
|
||||||
- `004_screen_status.sql` — Display-Zustand pro Screen (`screen_status`: screen_id, display_state, reported_at)
|
- `004_screen_status.sql` — Display-Zustand pro Screen (`screen_status`: screen_id, display_state, reported_at)
|
||||||
- `005_screen_schedules.sql` — Zeitplan pro Screen (`screen_schedules`: screen_id, schedule_enabled, power_on_time, power_off_time)
|
- `005_screen_schedules.sql` — Zeitplan pro Screen (`screen_schedules`: screen_id, schedule_enabled, power_on_time, power_off_time)
|
||||||
|
- `006_override.sql` — Spalte `override_on_until` in `screen_schedules` (per-Screen-Override) und Tabelle `global_override` (systemweiter Display-Override)
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ type App struct {
|
||||||
notifier *mqttnotifier.Notifier
|
notifier *mqttnotifier.Notifier
|
||||||
authStore *store.AuthStore
|
authStore *store.AuthStore
|
||||||
scheduleStore *store.ScreenScheduleStore
|
scheduleStore *store.ScreenScheduleStore
|
||||||
|
globalOverrideStore *store.GlobalOverrideStore
|
||||||
screenStore *store.ScreenStore
|
screenStore *store.ScreenStore
|
||||||
dbPool *db.Pool // V7: für db.Close() im Shutdown
|
dbPool *db.Pool // V7: für db.Close() im Shutdown
|
||||||
logger *log.Logger
|
logger *log.Logger
|
||||||
|
|
@ -62,6 +63,7 @@ func New() (*App, error) {
|
||||||
playlists := store.NewPlaylistStore(pool.Pool)
|
playlists := store.NewPlaylistStore(pool.Pool)
|
||||||
authStore := store.NewAuthStore(pool.Pool)
|
authStore := store.NewAuthStore(pool.Pool)
|
||||||
schedules := store.NewScreenScheduleStore(pool.Pool)
|
schedules := store.NewScreenScheduleStore(pool.Pool)
|
||||||
|
globalOverrides := store.NewGlobalOverrideStore(pool.Pool)
|
||||||
|
|
||||||
// Ensure admin user exists — generate a random password if none is configured.
|
// Ensure admin user exists — generate a random password if none is configured.
|
||||||
adminPassword := cfg.AdminPassword
|
adminPassword := cfg.AdminPassword
|
||||||
|
|
@ -101,6 +103,7 @@ func New() (*App, error) {
|
||||||
Notifier: notifier,
|
Notifier: notifier,
|
||||||
ScreenshotStore: ss,
|
ScreenshotStore: ss,
|
||||||
ScheduleStore: schedules,
|
ScheduleStore: schedules,
|
||||||
|
GlobalOverrideStore: globalOverrides,
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
UploadDir: cfg.UploadDir,
|
UploadDir: cfg.UploadDir,
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
|
|
@ -112,6 +115,7 @@ func New() (*App, error) {
|
||||||
notifier: notifier,
|
notifier: notifier,
|
||||||
authStore: authStore,
|
authStore: authStore,
|
||||||
scheduleStore: schedules,
|
scheduleStore: schedules,
|
||||||
|
globalOverrideStore: globalOverrides,
|
||||||
screenStore: screens,
|
screenStore: screens,
|
||||||
dbPool: pool, // V7: Referenz für Shutdown
|
dbPool: pool, // V7: Referenz für Shutdown
|
||||||
logger: logger,
|
logger: logger,
|
||||||
|
|
@ -145,8 +149,8 @@ func (a *App) Run() error {
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Display-Zeitplan-Scheduler
|
// Display-Zeitplan-Scheduler
|
||||||
go scheduler.Run(ctx, a.scheduleStore, a.screenStore, a.notifier)
|
go scheduler.Run(ctx, a.scheduleStore, a.screenStore, a.globalOverrideStore, a.notifier)
|
||||||
go scheduler.Reconcile(ctx, a.scheduleStore, a.screenStore, a.screenStore, a.notifier)
|
go scheduler.Reconcile(ctx, a.scheduleStore, a.screenStore, a.screenStore, a.globalOverrideStore, a.notifier)
|
||||||
|
|
||||||
// W2: Signal-Handler für Graceful Shutdown.
|
// W2: Signal-Handler für Graceful Shutdown.
|
||||||
sigCh := make(chan os.Signal, 1)
|
sigCh := make(chan os.Signal, 1)
|
||||||
|
|
|
||||||
14
server/backend/internal/db/migrations/006_override.sql
Normal file
14
server/backend/internal/db/migrations/006_override.sql
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
-- Migration 006: Globaler Override + per-Screen Override-Zeitpunkt
|
||||||
|
|
||||||
|
-- Globaler Override: immer maximal eine Zeile (id = 1 per CHECK-Constraint).
|
||||||
|
create table if not exists global_override (
|
||||||
|
id int primary key default 1,
|
||||||
|
type text not null, -- 'on' oder 'off'
|
||||||
|
until timestamptz not null,
|
||||||
|
set_at timestamptz not null default now(),
|
||||||
|
check (id = 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Per-Screen Override: Einschalten bis Zeitpunkt (null = kein Override).
|
||||||
|
alter table screen_schedules
|
||||||
|
add column if not exists override_on_until timestamptz;
|
||||||
138
server/backend/internal/httpapi/manage/override.go
Normal file
138
server/backend/internal/httpapi/manage/override.go
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
// server/backend/internal/httpapi/manage/override.go
|
||||||
|
package manage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.az-it.net/az/morz-infoboard/server/backend/internal/mqttnotifier"
|
||||||
|
"git.az-it.net/az/morz-infoboard/server/backend/internal/reqcontext"
|
||||||
|
"git.az-it.net/az/morz-infoboard/server/backend/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// globalOverrideStore ist das Interface für Handler-Tests.
|
||||||
|
type globalOverrideStore interface {
|
||||||
|
Get(ctx context.Context) (*store.GlobalOverride, error)
|
||||||
|
Upsert(ctx context.Context, overrideType string, until time.Time) error
|
||||||
|
Delete(ctx context.Context) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleGetGlobalOverride gibt den aktuellen globalen Override zurück (204 wenn keiner aktiv).
|
||||||
|
func HandleGetGlobalOverride(overrides globalOverrideStore) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
o, err := overrides.Get(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if o == nil || time.Now().After(o.Until) {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(o) //nolint:errcheck
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleSetGlobalOverride setzt den globalen Override und schickt sofort MQTT an alle Screens.
|
||||||
|
// Hinweis: Der Override wird global gespeichert und vom Reconciler auf alle Screens angewendet.
|
||||||
|
// Über authOnly haben alle eingeloggten Nutzer Zugriff; die sofortigen MQTT-Kommandos gehen
|
||||||
|
// jedoch nur an ihre zugänglichen Screens. Soll der Zugriff auf Admins beschränkt werden,
|
||||||
|
// authOnly durch authAdmin ersetzen.
|
||||||
|
func HandleSetGlobalOverride(overrides globalOverrideStore, screens *store.ScreenStore, notifier *mqttnotifier.Notifier) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Until time.Time `json:"until"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
http.Error(w, "invalid JSON", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Type != "on" && body.Type != "off" {
|
||||||
|
http.Error(w, `type must be "on" or "off"`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Until.IsZero() || !time.Now().Before(body.Until) {
|
||||||
|
http.Error(w, "until must be in the future", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := overrides.Upsert(r.Context(), body.Type, body.Until); err != nil {
|
||||||
|
slog.Error("set global override: upsert failed", "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sofort MQTT an alle zugänglichen Screens schicken (falls screens/notifier vorhanden)
|
||||||
|
if screens != nil && notifier != nil {
|
||||||
|
u := reqcontext.UserFromContext(r.Context())
|
||||||
|
var allScreens []*store.Screen
|
||||||
|
if u != nil {
|
||||||
|
switch u.Role {
|
||||||
|
case "admin":
|
||||||
|
allScreens, _ = screens.ListAll(r.Context())
|
||||||
|
default:
|
||||||
|
allScreens, _ = screens.GetAccessibleScreens(r.Context(), u.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
action := "display_" + body.Type
|
||||||
|
for _, sc := range allScreens {
|
||||||
|
if err := notifier.SendDisplayCommand(sc.Slug, action); err != nil {
|
||||||
|
slog.Warn("set global override: send command failed", "slug", sc.Slug, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleDeleteGlobalOverride entfernt den globalen Override.
|
||||||
|
func HandleDeleteGlobalOverride(overrides globalOverrideStore) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := overrides.Delete(r.Context()); err != nil {
|
||||||
|
slog.Error("delete global override: failed", "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleSetScreenOverride setzt oder löscht den per-Screen-Override (on_until: null → löschen).
|
||||||
|
func HandleSetScreenOverride(screens *store.ScreenStore, schedules *store.ScreenScheduleStore) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
screenSlug := r.PathValue("screenSlug")
|
||||||
|
screen, err := screens.GetBySlug(r.Context(), screenSlug)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "screen not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireScreenAccess(w, r, screen) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
OnUntil *time.Time `json:"on_until"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
http.Error(w, "invalid JSON", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.OnUntil != nil && !time.Now().Before(*body.OnUntil) {
|
||||||
|
http.Error(w, "on_until must be in the future", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := schedules.SetOverrideOnUntil(r.Context(), screen.ID, body.OnUntil); err != nil {
|
||||||
|
slog.Error("set screen override: db error", "screen_id", screen.ID, "err", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
98
server/backend/internal/httpapi/manage/override_test.go
Normal file
98
server/backend/internal/httpapi/manage/override_test.go
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
// server/backend/internal/httpapi/manage/override_test.go
|
||||||
|
package manage_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.az-it.net/az/morz-infoboard/server/backend/internal/httpapi/manage"
|
||||||
|
"git.az-it.net/az/morz-infoboard/server/backend/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Mocks ---
|
||||||
|
|
||||||
|
type mockGlobalOverrideStore struct {
|
||||||
|
current *store.GlobalOverride
|
||||||
|
upsertCalled bool
|
||||||
|
deleteCalled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockGlobalOverrideStore) Get(_ context.Context) (*store.GlobalOverride, error) {
|
||||||
|
return m.current, nil
|
||||||
|
}
|
||||||
|
func (m *mockGlobalOverrideStore) Upsert(_ context.Context, t string, u time.Time) error {
|
||||||
|
m.current = &store.GlobalOverride{Type: t, Until: u}
|
||||||
|
m.upsertCalled = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (m *mockGlobalOverrideStore) Delete(_ context.Context) error {
|
||||||
|
m.current = nil
|
||||||
|
m.deleteCalled = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tests ---
|
||||||
|
|
||||||
|
func TestHandleGetGlobalOverride_None(t *testing.T) {
|
||||||
|
h := manage.HandleGetGlobalOverride(&mockGlobalOverrideStore{})
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/global-override", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("want 204, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetGlobalOverride_Active(t *testing.T) {
|
||||||
|
until := time.Now().Add(time.Hour)
|
||||||
|
m := &mockGlobalOverrideStore{
|
||||||
|
current: &store.GlobalOverride{Type: "off", Until: until},
|
||||||
|
}
|
||||||
|
h := manage.HandleGetGlobalOverride(m)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/global-override", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("want 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
var resp store.GlobalOverride
|
||||||
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||||
|
t.Fatal("decode response:", err)
|
||||||
|
}
|
||||||
|
if resp.Type != "off" {
|
||||||
|
t.Errorf("want type=off, got %q", resp.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleSetGlobalOverride_InvalidType(t *testing.T) {
|
||||||
|
h := manage.HandleSetGlobalOverride(&mockGlobalOverrideStore{}, nil, nil)
|
||||||
|
body := `{"type":"maybe","until":"` + time.Now().Add(time.Hour).Format(time.RFC3339) + `"}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/global-override", bytes.NewBufferString(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("want 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleDeleteGlobalOverride(t *testing.T) {
|
||||||
|
m := &mockGlobalOverrideStore{
|
||||||
|
current: &store.GlobalOverride{Type: "off", Until: time.Now().Add(time.Hour)},
|
||||||
|
}
|
||||||
|
h := manage.HandleDeleteGlobalOverride(m)
|
||||||
|
req := httptest.NewRequest(http.MethodDelete, "/api/v1/global-override", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("want 204, got %d", w.Code)
|
||||||
|
}
|
||||||
|
if !m.deleteCalled {
|
||||||
|
t.Error("Delete() was not called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -982,6 +982,27 @@ const manageTmpl = `<!DOCTYPE html>
|
||||||
</div>
|
</div>
|
||||||
<p id="schedule-save-ok" class="save-ok mt-2" style="font-size:.8rem">✓ Gespeichert</p>
|
<p id="schedule-save-ok" class="save-ok mt-2" style="font-size:.8rem">✓ Gespeichert</p>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Per-Screen Override (Einschalten bis) -->
|
||||||
|
<div class="box mb-3">
|
||||||
|
<h3 class="title is-6 mb-2">Einschalten bis (Override)</h3>
|
||||||
|
{{if not_expired .Schedule.OverrideOnUntil}}
|
||||||
|
<p style="font-size:.875rem;color:#059669;margin-bottom:.5rem">
|
||||||
|
⏰ Aktiv bis {{.Schedule.OverrideOnUntil.Format "02.01.2006 15:04"}}
|
||||||
|
</p>
|
||||||
|
<button class="button is-small is-light" type="button"
|
||||||
|
onclick="clearScreenOverridePage()">Override aufheben</button>
|
||||||
|
{{else}}
|
||||||
|
<p style="font-size:.8rem;color:#6b7280;margin-bottom:.5rem">
|
||||||
|
Überschreibt Zeitplan und Wochenend-Sperre — Monitor bleibt bis zum angegebenen Zeitpunkt eingeschaltet.
|
||||||
|
</p>
|
||||||
|
<div style="display:flex;gap:.5rem;align-items:center;flex-wrap:wrap">
|
||||||
|
<input type="datetime-local" id="screen-override-until" class="input is-small" style="width:16rem">
|
||||||
|
<button class="button is-small is-success" type="button"
|
||||||
|
onclick="setScreenOverridePage()">Setzen</button>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
<p id="screen-override-ok" class="save-ok mt-2" style="font-size:.8rem">✓ Gespeichert</p>
|
||||||
|
</div>
|
||||||
<!-- Upload (collapsed) -->
|
<!-- Upload (collapsed) -->
|
||||||
<div class="box mb-3">
|
<div class="box mb-3">
|
||||||
<details id="upload-details">
|
<details id="upload-details">
|
||||||
|
|
@ -1189,6 +1210,33 @@ function saveSchedule() {
|
||||||
}).catch(function() { showToast('Netzwerkfehler', 'is-danger'); });
|
}).catch(function() { showToast('Netzwerkfehler', 'is-danger'); });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setScreenOverridePage() {
|
||||||
|
var val = document.getElementById('screen-override-until').value;
|
||||||
|
if (!val) { showToast('Bitte Datum und Uhrzeit angeben', 'is-warning'); return; }
|
||||||
|
var dt = new Date(val);
|
||||||
|
fetch('/api/v1/screens/' + SCREEN_SLUG + '/override', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf(), 'X-Requested-With': 'fetch'},
|
||||||
|
body: JSON.stringify({on_until: dt.toISOString()})
|
||||||
|
}).then(function(r) {
|
||||||
|
if (r.ok) {
|
||||||
|
var ok = document.getElementById('screen-override-ok');
|
||||||
|
if (ok) { ok.classList.add('show'); setTimeout(function() { ok.classList.remove('show'); }, 2000); }
|
||||||
|
} else { showToast('Fehler beim Setzen des Overrides', 'is-danger'); }
|
||||||
|
}).catch(function() { showToast('Netzwerkfehler', 'is-danger'); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearScreenOverridePage() {
|
||||||
|
fetch('/api/v1/screens/' + SCREEN_SLUG + '/override', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf(), 'X-Requested-With': 'fetch'},
|
||||||
|
body: JSON.stringify({on_until: null})
|
||||||
|
}).then(function(r) {
|
||||||
|
if (r.ok) { location.reload(); }
|
||||||
|
else { showToast('Fehler beim Aufheben des Overrides', 'is-danger'); }
|
||||||
|
}).catch(function() { showToast('Netzwerkfehler', 'is-danger'); });
|
||||||
|
}
|
||||||
|
|
||||||
// ─── ?msg= toast ─────────────────────────────────────────────────
|
// ─── ?msg= toast ─────────────────────────────────────────────────
|
||||||
(function() {
|
(function() {
|
||||||
var msg = new URLSearchParams(window.location.search).get('msg');
|
var msg = new URLSearchParams(window.location.search).get('msg');
|
||||||
|
|
@ -1392,6 +1440,29 @@ const screenOverviewTmpl = `<!DOCTYPE html>
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1 class="title is-4 mb-5">Meine Bildschirme</h1>
|
<h1 class="title is-4 mb-5">Meine Bildschirme</h1>
|
||||||
|
<!-- Globaler Override-Banner -->
|
||||||
|
<div id="global-override-section" style="margin-bottom:1rem">
|
||||||
|
{{if .GlobalOverride}}
|
||||||
|
<div class="notification {{if eq .GlobalOverride.Type "off"}}is-warning{{else}}is-info{{end}} is-light py-2 px-3" style="display:flex;align-items:center;gap:1rem;flex-wrap:wrap">
|
||||||
|
<span>
|
||||||
|
Alle Monitore <strong>{{if eq .GlobalOverride.Type "off"}}ausgeschaltet{{else}}eingeschaltet{{end}}</strong>
|
||||||
|
bis {{.GlobalOverride.Until.Format "02.01.2006 15:04"}}
|
||||||
|
</span>
|
||||||
|
<button class="button is-small" type="button" onclick="deleteGlobalOverride()">Override aufheben</button>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div style="display:flex;gap:.5rem;flex-wrap:wrap;align-items:center">
|
||||||
|
<button class="button is-small is-danger is-light" type="button" onclick="showGlobalOverrideForm('off')">Alle ausschalten bis…</button>
|
||||||
|
<button class="button is-small is-success is-light" type="button" onclick="showGlobalOverrideForm('on')">Alle einschalten bis…</button>
|
||||||
|
<span id="override-result" style="font-size:.8rem;color:#6b7280"></span>
|
||||||
|
</div>
|
||||||
|
<div id="global-override-form" style="display:none;margin-top:.5rem;gap:.5rem;align-items:center;flex-wrap:wrap">
|
||||||
|
<input type="datetime-local" id="global-override-until" class="input is-small" style="width:16rem">
|
||||||
|
<button class="button is-small is-primary" type="button" onclick="setGlobalOverride()">Setzen</button>
|
||||||
|
<button class="button is-small is-light" type="button" onclick="hideGlobalOverrideForm()">Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
{{if gt (len .Cards) 1}}
|
{{if gt (len .Cards) 1}}
|
||||||
<div class="bulk-bar">
|
<div class="bulk-bar">
|
||||||
<span style="font-size:.875rem;font-weight:600;color:#374151">Alle Displays:</span>
|
<span style="font-size:.875rem;font-weight:600;color:#374151">Alle Displays:</span>
|
||||||
|
|
@ -1424,6 +1495,24 @@ const screenOverviewTmpl = `<!DOCTYPE html>
|
||||||
<button class="button is-small is-danger is-light" type="button"
|
<button class="button is-small is-danger is-light" type="button"
|
||||||
onclick="sendDisplayCmd('{{.Screen.Slug}}','off')">Aus</button>
|
onclick="sendDisplayCmd('{{.Screen.Slug}}','off')">Aus</button>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Per-Screen Override -->
|
||||||
|
<div style="margin-top:.5rem;font-size:.8rem">
|
||||||
|
{{if .OverrideOnUntil}}
|
||||||
|
<span style="color:#059669">⏰ Ein bis {{.OverrideOnUntil.Format "02.01. 15:04"}}</span>
|
||||||
|
<button class="button is-small is-light" style="padding:0 .4rem;height:1.4rem" type="button"
|
||||||
|
onclick="clearScreenOverride('{{.Screen.Slug}}')">✕</button>
|
||||||
|
{{else}}
|
||||||
|
<details style="display:inline">
|
||||||
|
<summary style="cursor:pointer;color:#6b7280">Einschalten bis…</summary>
|
||||||
|
<div style="display:flex;gap:.3rem;align-items:center;margin-top:.3rem;flex-wrap:wrap">
|
||||||
|
<input type="datetime-local" id="override-until-{{.Screen.Slug}}"
|
||||||
|
class="input is-small" style="width:13rem;font-size:.75rem">
|
||||||
|
<button class="button is-small is-success is-light" type="button"
|
||||||
|
onclick="setScreenOverride('{{.Screen.Slug}}')">Setzen</button>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1513,6 +1602,68 @@ function bulkDisplay(state) {
|
||||||
}).catch(function(){});
|
}).catch(function(){});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var _globalOverrideType = '';
|
||||||
|
|
||||||
|
function showGlobalOverrideForm(type) {
|
||||||
|
_globalOverrideType = type;
|
||||||
|
var form = document.getElementById('global-override-form');
|
||||||
|
if (form) form.style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideGlobalOverrideForm() {
|
||||||
|
var form = document.getElementById('global-override-form');
|
||||||
|
if (form) form.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setGlobalOverride() {
|
||||||
|
var val = document.getElementById('global-override-until').value;
|
||||||
|
if (!val) {
|
||||||
|
document.getElementById('override-result').textContent = 'Bitte Datum und Uhrzeit angeben';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var dt = new Date(val);
|
||||||
|
fetch('/api/v1/global-override', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf(), 'X-Requested-With': 'fetch'},
|
||||||
|
body: JSON.stringify({type: _globalOverrideType, until: dt.toISOString()})
|
||||||
|
}).then(function(r) {
|
||||||
|
if (r.ok) { location.reload(); }
|
||||||
|
else { document.getElementById('override-result').textContent = 'Fehler beim Setzen'; }
|
||||||
|
}).catch(function() { document.getElementById('override-result').textContent = 'Netzwerkfehler'; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteGlobalOverride() {
|
||||||
|
fetch('/api/v1/global-override', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {'X-CSRF-Token': getCsrf(), 'X-Requested-With': 'fetch'}
|
||||||
|
}).then(function(r) {
|
||||||
|
if (r.ok) { location.reload(); }
|
||||||
|
}).catch(function(){});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setScreenOverride(slug) {
|
||||||
|
var val = document.getElementById('override-until-' + slug).value;
|
||||||
|
if (!val) return;
|
||||||
|
var dt = new Date(val);
|
||||||
|
fetch('/api/v1/screens/' + slug + '/override', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf(), 'X-Requested-With': 'fetch'},
|
||||||
|
body: JSON.stringify({on_until: dt.toISOString()})
|
||||||
|
}).then(function(r) {
|
||||||
|
if (r.ok) { location.reload(); }
|
||||||
|
}).catch(function(){});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearScreenOverride(slug) {
|
||||||
|
fetch('/api/v1/screens/' + slug + '/override', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf(), 'X-Requested-With': 'fetch'},
|
||||||
|
body: JSON.stringify({on_until: null})
|
||||||
|
}).then(function(r) {
|
||||||
|
if (r.ok) { location.reload(); }
|
||||||
|
}).catch(function(){});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>`
|
</html>`
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,9 @@ var tmplFuncs = template.FuncMap{
|
||||||
}
|
}
|
||||||
return t.Format("02.01.2006 15:04")
|
return t.Format("02.01.2006 15:04")
|
||||||
},
|
},
|
||||||
|
"not_expired": func(t *time.Time) bool {
|
||||||
|
return t != nil && time.Now().Before(*t)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleAdminUI renders the admin overview page (screens + users tabs).
|
// HandleAdminUI renders the admin overview page (screens + users tabs).
|
||||||
|
|
@ -283,10 +286,11 @@ func HandleRemoveUserFromScreen(screens *store.ScreenStore) http.HandlerFunc {
|
||||||
type screenCard struct {
|
type screenCard struct {
|
||||||
Screen *store.Screen
|
Screen *store.Screen
|
||||||
DisplayState string
|
DisplayState string
|
||||||
|
OverrideOnUntil *time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleScreenOverview renders a card-based overview of all accessible screens for a screen_user.
|
// HandleScreenOverview renders a card-based overview of all accessible screens for a screen_user.
|
||||||
func HandleScreenOverview(screens *store.ScreenStore, notifier *mqttnotifier.Notifier, cfg config.Config) http.HandlerFunc {
|
func HandleScreenOverview(screens *store.ScreenStore, schedules *store.ScreenScheduleStore, overrides *store.GlobalOverrideStore, notifier *mqttnotifier.Notifier, cfg config.Config) http.HandlerFunc {
|
||||||
t := template.Must(template.New("screenOverview").Funcs(tmplFuncs).Parse(screenOverviewTmpl))
|
t := template.Must(template.New("screenOverview").Funcs(tmplFuncs).Parse(screenOverviewTmpl))
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
u := reqcontext.UserFromContext(r.Context())
|
u := reqcontext.UserFromContext(r.Context())
|
||||||
|
|
@ -310,11 +314,23 @@ func HandleScreenOverview(screens *store.ScreenStore, notifier *mqttnotifier.Not
|
||||||
cards := make([]screenCard, 0, len(accessible))
|
cards := make([]screenCard, 0, len(accessible))
|
||||||
for _, sc := range accessible {
|
for _, sc := range accessible {
|
||||||
ds, _ := screens.GetDisplayState(r.Context(), sc.ID)
|
ds, _ := screens.GetDisplayState(r.Context(), sc.ID)
|
||||||
cards = append(cards, screenCard{Screen: sc, DisplayState: ds})
|
sched, _ := schedules.Get(r.Context(), sc.ID)
|
||||||
|
var overrideOnUntil *time.Time
|
||||||
|
if sched != nil && sched.OverrideOnUntil != nil && time.Now().Before(*sched.OverrideOnUntil) {
|
||||||
|
overrideOnUntil = sched.OverrideOnUntil
|
||||||
}
|
}
|
||||||
|
cards = append(cards, screenCard{Screen: sc, DisplayState: ds, OverrideOnUntil: overrideOnUntil})
|
||||||
|
}
|
||||||
|
|
||||||
|
var activeOverride *store.GlobalOverride
|
||||||
|
if o, err := overrides.Get(r.Context()); err == nil && o != nil && time.Now().Before(o.Until) {
|
||||||
|
activeOverride = o
|
||||||
|
}
|
||||||
|
|
||||||
renderTemplate(w, t, map[string]any{
|
renderTemplate(w, t, map[string]any{
|
||||||
"Cards": cards,
|
"Cards": cards,
|
||||||
"CSRFToken": csrfToken,
|
"CSRFToken": csrfToken,
|
||||||
|
"GlobalOverride": activeOverride,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ type RouterDeps struct {
|
||||||
Notifier *mqttnotifier.Notifier
|
Notifier *mqttnotifier.Notifier
|
||||||
ScreenshotStore *ScreenshotStore
|
ScreenshotStore *ScreenshotStore
|
||||||
ScheduleStore *store.ScreenScheduleStore
|
ScheduleStore *store.ScreenScheduleStore
|
||||||
|
GlobalOverrideStore *store.GlobalOverrideStore
|
||||||
Config config.Config
|
Config config.Config
|
||||||
UploadDir string
|
UploadDir string
|
||||||
Logger *log.Logger
|
Logger *log.Logger
|
||||||
|
|
@ -164,7 +165,7 @@ func registerManageRoutes(mux *http.ServeMux, d RouterDeps) {
|
||||||
// ── Playlist management UI ────────────────────────────────────────────
|
// ── Playlist management UI ────────────────────────────────────────────
|
||||||
// authScreen enforces that screen_user only accesses their permitted screens.
|
// authScreen enforces that screen_user only accesses their permitted screens.
|
||||||
mux.Handle("GET /manage",
|
mux.Handle("GET /manage",
|
||||||
authOnly(http.HandlerFunc(manage.HandleScreenOverview(d.ScreenStore, notifier, d.Config))))
|
authOnly(http.HandlerFunc(manage.HandleScreenOverview(d.ScreenStore, d.ScheduleStore, d.GlobalOverrideStore, notifier, d.Config))))
|
||||||
mux.Handle("GET /manage/{screenSlug}",
|
mux.Handle("GET /manage/{screenSlug}",
|
||||||
authScreen(http.HandlerFunc(manage.HandleManageUI(d.TenantStore, d.ScreenStore, d.ScheduleStore, d.MediaStore, d.PlaylistStore, d.Config, notifier))))
|
authScreen(http.HandlerFunc(manage.HandleManageUI(d.TenantStore, d.ScreenStore, d.ScheduleStore, d.MediaStore, d.PlaylistStore, d.Config, notifier))))
|
||||||
mux.Handle("POST /manage/{screenSlug}/upload",
|
mux.Handle("POST /manage/{screenSlug}/upload",
|
||||||
|
|
@ -192,6 +193,18 @@ func registerManageRoutes(mux *http.ServeMux, d RouterDeps) {
|
||||||
mux.Handle("POST /api/v1/screens/{screenSlug}/schedule",
|
mux.Handle("POST /api/v1/screens/{screenSlug}/schedule",
|
||||||
authScreen(http.HandlerFunc(manage.HandleUpdateSchedule(d.ScreenStore, d.ScheduleStore))))
|
authScreen(http.HandlerFunc(manage.HandleUpdateSchedule(d.ScreenStore, d.ScheduleStore))))
|
||||||
|
|
||||||
|
// ── Globaler Override ────────────────────────────────────────────────
|
||||||
|
mux.Handle("GET /api/v1/global-override",
|
||||||
|
authOnly(http.HandlerFunc(manage.HandleGetGlobalOverride(d.GlobalOverrideStore))))
|
||||||
|
mux.Handle("POST /api/v1/global-override",
|
||||||
|
authOnly(http.HandlerFunc(manage.HandleSetGlobalOverride(d.GlobalOverrideStore, d.ScreenStore, notifier))))
|
||||||
|
mux.Handle("DELETE /api/v1/global-override",
|
||||||
|
authOnly(http.HandlerFunc(manage.HandleDeleteGlobalOverride(d.GlobalOverrideStore))))
|
||||||
|
|
||||||
|
// ── Per-Screen Override ───────────────────────────────────────────────
|
||||||
|
mux.Handle("POST /api/v1/screens/{screenSlug}/override",
|
||||||
|
authScreen(http.HandlerFunc(manage.HandleSetScreenOverride(d.ScreenStore, d.ScheduleStore))))
|
||||||
|
|
||||||
// ── JSON API — screens ────────────────────────────────────────────────
|
// ── JSON API — screens ────────────────────────────────────────────────
|
||||||
// Self-registration: no auth (player calls this on startup).
|
// Self-registration: no auth (player calls this on startup).
|
||||||
mux.HandleFunc("POST /api/v1/screens/register",
|
mux.HandleFunc("POST /api/v1/screens/register",
|
||||||
|
|
|
||||||
|
|
@ -25,15 +25,20 @@ type DisplayStateGetter interface {
|
||||||
GetDisplayState(ctx context.Context, screenID string) (string, error)
|
GetDisplayState(ctx context.Context, screenID string) (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AllScreensLister lädt alle bekannten Screens.
|
||||||
|
type AllScreensLister interface {
|
||||||
|
ListAll(ctx context.Context) ([]*store.Screen, error)
|
||||||
|
}
|
||||||
|
|
||||||
// Run startet den Scheduler-Loop. Blockiert bis ctx abgebrochen wird.
|
// Run startet den Scheduler-Loop. Blockiert bis ctx abgebrochen wird.
|
||||||
func Run(ctx context.Context, schedules *store.ScreenScheduleStore, screens ScreenSlugGetter, notifier DisplayCommander) {
|
func Run(ctx context.Context, schedules *store.ScreenScheduleStore, screens ScreenSlugGetter, globalOverrides *store.GlobalOverrideStore, notifier DisplayCommander) {
|
||||||
ticker := time.NewTicker(1 * time.Minute)
|
ticker := time.NewTicker(1 * time.Minute)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
check(ctx, schedules, screens, notifier)
|
check(ctx, schedules, screens, globalOverrides, notifier)
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -41,39 +46,50 @@ func Run(ctx context.Context, schedules *store.ScreenScheduleStore, screens Scre
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reconcile läuft alle 5 Minuten und gleicht Ist- und Soll-Zustand ab.
|
// Reconcile läuft alle 5 Minuten und gleicht Ist- und Soll-Zustand ab.
|
||||||
func Reconcile(ctx context.Context, schedules *store.ScreenScheduleStore, screens ScreenSlugGetter, states DisplayStateGetter, notifier DisplayCommander) {
|
func Reconcile(ctx context.Context, schedules *store.ScreenScheduleStore, screens AllScreensLister, states DisplayStateGetter, globalOverrides *store.GlobalOverrideStore, notifier DisplayCommander) {
|
||||||
ticker := time.NewTicker(5 * time.Minute)
|
ticker := time.NewTicker(5 * time.Minute)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
reconcile(ctx, schedules, screens, states, notifier)
|
reconcile(ctx, schedules, screens, states, globalOverrides, notifier)
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func reconcile(ctx context.Context, schedules *store.ScreenScheduleStore, screens ScreenSlugGetter, states DisplayStateGetter, notifier DisplayCommander) {
|
func reconcile(ctx context.Context, schedules *store.ScreenScheduleStore, allScreens AllScreensLister, states DisplayStateGetter, globalOverrides *store.GlobalOverrideStore, notifier DisplayCommander) {
|
||||||
now := time.Now().Format("15:04")
|
now := time.Now()
|
||||||
|
|
||||||
enabled, err := schedules.ListEnabled(ctx)
|
screenList, err := allScreens.ListAll(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("reconciler: list enabled schedules failed", "err", err)
|
slog.Error("reconciler: list all screens failed", "err", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, sc := range enabled {
|
globalOverride, err := globalOverrides.Get(ctx)
|
||||||
if sc.PowerOnTime == "" || sc.PowerOffTime == "" {
|
if err != nil {
|
||||||
|
slog.Warn("reconciler: get global override failed", "err", err)
|
||||||
|
// nicht fatal — ohne Override fortfahren
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, screen := range screenList {
|
||||||
|
sc, err := schedules.Get(ctx, screen.ID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("reconciler: get schedule failed", "screen_id", screen.ID, "err", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
want := desiredState(sc.PowerOnTime, sc.PowerOffTime, now)
|
want, shouldControl := resolveDesiredState(*sc, globalOverride, now)
|
||||||
|
if !shouldControl {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
got, err := states.GetDisplayState(ctx, sc.ScreenID)
|
got, err := states.GetDisplayState(ctx, screen.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("reconciler: get display state failed", "screen_id", sc.ScreenID, "err", err)
|
slog.Warn("reconciler: get display state failed", "screen_id", screen.ID, "err", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -81,17 +97,11 @@ func reconcile(ctx context.Context, schedules *store.ScreenScheduleStore, screen
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
screen, err := screens.GetByID(ctx, sc.ScreenID)
|
|
||||||
if err != nil {
|
|
||||||
slog.Warn("reconciler: screen not found", "screen_id", sc.ScreenID, "err", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
action := "display_" + want
|
action := "display_" + want
|
||||||
if err := notifier.SendDisplayCommand(screen.Slug, action); err != nil {
|
if err := notifier.SendDisplayCommand(screen.Slug, action); err != nil {
|
||||||
slog.Error("reconciler: send command failed", "screen_id", sc.ScreenID, "action", action, "err", err)
|
slog.Error("reconciler: send command failed", "screen_id", screen.ID, "action", action, "err", err)
|
||||||
} else {
|
} else {
|
||||||
slog.Info("reconciler: corrected display state", "screen_id", sc.ScreenID, "slug", screen.Slug, "was", got, "want", want)
|
slog.Info("reconciler: corrected display state", "screen_id", screen.ID, "slug", screen.Slug, "was", got, "want", want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -113,10 +123,49 @@ func desiredState(onTime, offTime, now string) string {
|
||||||
return "off"
|
return "off"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveDesiredState ermittelt den Soll-Zustand eines Screens unter Berücksichtigung
|
||||||
|
// aller Prioritätsstufen:
|
||||||
|
// 1. per-Screen override_on_until (höchste Priorität)
|
||||||
|
// 2. globaler Override
|
||||||
|
// 3. Wochenende (Sa/So)
|
||||||
|
// 4. normaler Zeitplan
|
||||||
|
//
|
||||||
|
// Gibt ("", false) zurück wenn keine Automatisierung aktiv ist.
|
||||||
|
func resolveDesiredState(sc store.ScreenSchedule, globalOverride *store.GlobalOverride, now time.Time) (desired string, shouldControl bool) {
|
||||||
|
// 1. Per-Screen-Override: überschreibt alles
|
||||||
|
if sc.OverrideOnUntil != nil && now.Before(*sc.OverrideOnUntil) {
|
||||||
|
return "on", true
|
||||||
|
}
|
||||||
|
// 2. Globaler Override
|
||||||
|
if globalOverride != nil && now.Before(globalOverride.Until) {
|
||||||
|
return globalOverride.Type, true
|
||||||
|
}
|
||||||
|
// 3. Wochenende: Zeitpläne werden ignoriert, Monitore bleiben aus
|
||||||
|
wd := now.Weekday()
|
||||||
|
if wd == time.Saturday || wd == time.Sunday {
|
||||||
|
return "off", true
|
||||||
|
}
|
||||||
|
// 4. Normaler Zeitplan
|
||||||
|
if !sc.ScheduleEnabled || (sc.PowerOnTime == "" && sc.PowerOffTime == "") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return desiredState(sc.PowerOnTime, sc.PowerOffTime, now.Format("15:04")), true
|
||||||
|
}
|
||||||
|
|
||||||
// check prüft alle aktiven Zeitpläne und sendet ggf. Befehle.
|
// check prüft alle aktiven Zeitpläne und sendet ggf. Befehle.
|
||||||
func check(ctx context.Context, schedules *store.ScreenScheduleStore, screens ScreenSlugGetter, notifier DisplayCommander) {
|
func check(ctx context.Context, schedules *store.ScreenScheduleStore, screens ScreenSlugGetter, globalOverrides *store.GlobalOverrideStore, notifier DisplayCommander) {
|
||||||
// Uses process-local timezone — ensure TZ env var is set in the container (e.g. Europe/Berlin).
|
// Uses process-local timezone — ensure TZ env var is set in the container (e.g. Europe/Berlin).
|
||||||
now := time.Now().Format("15:04")
|
now := time.Now()
|
||||||
|
nowStr := now.Format("15:04")
|
||||||
|
|
||||||
|
// Wochenende: keine Einschalte-Kommandos senden
|
||||||
|
isWeekend := now.Weekday() == time.Saturday || now.Weekday() == time.Sunday
|
||||||
|
|
||||||
|
// Globaler Override "off" aktiv?
|
||||||
|
globalOverrideOff := false
|
||||||
|
if o, err := globalOverrides.Get(ctx); err == nil && o != nil && now.Before(o.Until) && o.Type == "off" {
|
||||||
|
globalOverrideOff = true
|
||||||
|
}
|
||||||
|
|
||||||
enabled, err := schedules.ListEnabled(ctx)
|
enabled, err := schedules.ListEnabled(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -132,9 +181,9 @@ func check(ctx context.Context, schedules *store.ScreenScheduleStore, screens Sc
|
||||||
}
|
}
|
||||||
|
|
||||||
var action string
|
var action string
|
||||||
if sc.PowerOnTime != "" && sc.PowerOnTime == now {
|
if sc.PowerOnTime != "" && sc.PowerOnTime == nowStr {
|
||||||
action = "display_on"
|
action = "display_on"
|
||||||
} else if sc.PowerOffTime != "" && sc.PowerOffTime == now {
|
} else if sc.PowerOffTime != "" && sc.PowerOffTime == nowStr {
|
||||||
action = "display_off"
|
action = "display_off"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -142,6 +191,19 @@ func check(ctx context.Context, schedules *store.ScreenScheduleStore, screens Sc
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// display_on unterdrücken wenn per-Screen-Override, Wochenende oder globaler Override "off"
|
||||||
|
if action == "display_on" {
|
||||||
|
if sc.OverrideOnUntil != nil && now.Before(*sc.OverrideOnUntil) {
|
||||||
|
// per-Screen Override aktiv → Kommando trotzdem senden (Override = on)
|
||||||
|
} else if isWeekend {
|
||||||
|
slog.Info("scheduler: display_on unterdrückt (Wochenende)", "screen_id", sc.ScreenID)
|
||||||
|
continue
|
||||||
|
} else if globalOverrideOff {
|
||||||
|
slog.Info("scheduler: display_on unterdrückt (globaler Override off)", "screen_id", sc.ScreenID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := notifier.SendDisplayCommand(screen.Slug, action); err != nil {
|
if err := notifier.SendDisplayCommand(screen.Slug, action); err != nil {
|
||||||
slog.Error("scheduler: send command failed", "screen_id", sc.ScreenID, "action", action, "err", err)
|
slog.Error("scheduler: send command failed", "screen_id", sc.ScreenID, "action", action, "err", err)
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
129
server/backend/internal/scheduler/scheduler_test.go
Normal file
129
server/backend/internal/scheduler/scheduler_test.go
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
package scheduler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.az-it.net/az/morz-infoboard/server/backend/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ptrTime(t time.Time) *time.Time { return &t }
|
||||||
|
|
||||||
|
func TestResolveDesiredState(t *testing.T) {
|
||||||
|
// Donnerstag 10:00 UTC
|
||||||
|
thu := time.Date(2026, 3, 26, 10, 0, 0, 0, time.UTC)
|
||||||
|
// Samstag 10:00 UTC
|
||||||
|
sat := time.Date(2026, 3, 28, 10, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
sc store.ScreenSchedule
|
||||||
|
override *store.GlobalOverride
|
||||||
|
now time.Time
|
||||||
|
wantDesired string
|
||||||
|
wantControl bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "per-screen override aktiv → on",
|
||||||
|
sc: store.ScreenSchedule{OverrideOnUntil: ptrTime(thu.Add(time.Hour))},
|
||||||
|
now: thu,
|
||||||
|
wantDesired: "on", wantControl: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "per-screen override abgelaufen → fällt durch zu Zeitplan",
|
||||||
|
sc: store.ScreenSchedule{
|
||||||
|
ScheduleEnabled: true,
|
||||||
|
PowerOnTime: "09:00", PowerOffTime: "17:00",
|
||||||
|
OverrideOnUntil: ptrTime(thu.Add(-time.Hour)), // abgelaufen
|
||||||
|
},
|
||||||
|
now: thu,
|
||||||
|
wantDesired: "on", wantControl: true, // Zeitplan: 09:00–17:00, jetzt 10:00
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "per-screen override schlägt globalen Override off",
|
||||||
|
sc: store.ScreenSchedule{OverrideOnUntil: ptrTime(thu.Add(time.Hour))},
|
||||||
|
override: &store.GlobalOverride{
|
||||||
|
Type: "off",
|
||||||
|
Until: thu.Add(time.Hour),
|
||||||
|
},
|
||||||
|
now: thu,
|
||||||
|
wantDesired: "on", wantControl: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "globaler Override off aktiv → off",
|
||||||
|
sc: store.ScreenSchedule{ScheduleEnabled: true, PowerOnTime: "09:00", PowerOffTime: "17:00"},
|
||||||
|
override: &store.GlobalOverride{
|
||||||
|
Type: "off",
|
||||||
|
Until: thu.Add(time.Hour),
|
||||||
|
},
|
||||||
|
now: thu,
|
||||||
|
wantDesired: "off", wantControl: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "globaler Override on aktiv → on",
|
||||||
|
sc: store.ScreenSchedule{},
|
||||||
|
override: &store.GlobalOverride{
|
||||||
|
Type: "on",
|
||||||
|
Until: thu.Add(time.Hour),
|
||||||
|
},
|
||||||
|
now: thu,
|
||||||
|
wantDesired: "on", wantControl: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "globaler Override abgelaufen → fällt durch",
|
||||||
|
sc: store.ScreenSchedule{ScheduleEnabled: true, PowerOnTime: "09:00", PowerOffTime: "17:00"},
|
||||||
|
override: &store.GlobalOverride{
|
||||||
|
Type: "off",
|
||||||
|
Until: thu.Add(-time.Hour), // abgelaufen
|
||||||
|
},
|
||||||
|
now: thu,
|
||||||
|
wantDesired: "on", wantControl: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Wochenende → off",
|
||||||
|
sc: store.ScreenSchedule{ScheduleEnabled: true, PowerOnTime: "09:00", PowerOffTime: "17:00"},
|
||||||
|
now: sat,
|
||||||
|
wantDesired: "off", wantControl: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Wochenende + per-screen override → on",
|
||||||
|
sc: store.ScreenSchedule{OverrideOnUntil: ptrTime(sat.Add(time.Hour))},
|
||||||
|
now: sat,
|
||||||
|
wantDesired: "on", wantControl: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "normaler Zeitplan: innerhalb → on",
|
||||||
|
sc: store.ScreenSchedule{ScheduleEnabled: true, PowerOnTime: "09:00", PowerOffTime: "17:00"},
|
||||||
|
now: thu, // 10:00
|
||||||
|
wantDesired: "on", wantControl: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "normaler Zeitplan: außerhalb → off",
|
||||||
|
sc: store.ScreenSchedule{ScheduleEnabled: true, PowerOnTime: "09:00", PowerOffTime: "17:00"},
|
||||||
|
now: time.Date(2026, 3, 26, 8, 0, 0, 0, time.UTC), // 08:00
|
||||||
|
wantDesired: "off", wantControl: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "kein Zeitplan, kein Override → keine Steuerung",
|
||||||
|
sc: store.ScreenSchedule{},
|
||||||
|
now: thu,
|
||||||
|
wantDesired: "", wantControl: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Zeitplan deaktiviert → keine Steuerung",
|
||||||
|
sc: store.ScreenSchedule{ScheduleEnabled: false, PowerOnTime: "09:00", PowerOffTime: "17:00"},
|
||||||
|
now: thu,
|
||||||
|
wantDesired: "", wantControl: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
gotDesired, gotControl := resolveDesiredState(tt.sc, tt.override, tt.now)
|
||||||
|
if gotDesired != tt.wantDesired || gotControl != tt.wantControl {
|
||||||
|
t.Errorf("resolveDesiredState() = (%q, %v), want (%q, %v)",
|
||||||
|
gotDesired, gotControl, tt.wantDesired, tt.wantControl)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -46,6 +46,7 @@ type ScreenSchedule struct {
|
||||||
ScheduleEnabled bool `json:"schedule_enabled"`
|
ScheduleEnabled bool `json:"schedule_enabled"`
|
||||||
PowerOnTime string `json:"power_on_time"`
|
PowerOnTime string `json:"power_on_time"`
|
||||||
PowerOffTime string `json:"power_off_time"`
|
PowerOffTime string `json:"power_off_time"`
|
||||||
|
OverrideOnUntil *time.Time `json:"override_on_until,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MediaAsset struct {
|
type MediaAsset struct {
|
||||||
|
|
@ -653,9 +654,9 @@ func scanPlaylistItem(row interface {
|
||||||
func (s *ScreenScheduleStore) Get(ctx context.Context, screenID string) (*ScreenSchedule, error) {
|
func (s *ScreenScheduleStore) Get(ctx context.Context, screenID string) (*ScreenSchedule, error) {
|
||||||
var sc ScreenSchedule
|
var sc ScreenSchedule
|
||||||
err := s.pool.QueryRow(ctx,
|
err := s.pool.QueryRow(ctx,
|
||||||
`select screen_id, schedule_enabled, power_on_time, power_off_time
|
`select screen_id, schedule_enabled, power_on_time, power_off_time, override_on_until
|
||||||
from screen_schedules where screen_id = $1`, screenID).
|
from screen_schedules where screen_id = $1`, screenID).
|
||||||
Scan(&sc.ScreenID, &sc.ScheduleEnabled, &sc.PowerOnTime, &sc.PowerOffTime)
|
Scan(&sc.ScreenID, &sc.ScheduleEnabled, &sc.PowerOnTime, &sc.PowerOffTime, &sc.OverrideOnUntil)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return &ScreenSchedule{ScreenID: screenID}, nil
|
return &ScreenSchedule{ScreenID: screenID}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -666,6 +667,8 @@ func (s *ScreenScheduleStore) Get(ctx context.Context, screenID string) (*Screen
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upsert speichert oder aktualisiert den Zeitplan eines Screens.
|
// Upsert speichert oder aktualisiert den Zeitplan eines Screens.
|
||||||
|
// Hinweis: override_on_until wird hier bewusst nicht angefasst – das ist
|
||||||
|
// ausschließlich Aufgabe von SetOverrideOnUntil (saubere Trennung, kein Datenverlust).
|
||||||
func (s *ScreenScheduleStore) Upsert(ctx context.Context, sc *ScreenSchedule) error {
|
func (s *ScreenScheduleStore) Upsert(ctx context.Context, sc *ScreenSchedule) error {
|
||||||
_, err := s.pool.Exec(ctx,
|
_, err := s.pool.Exec(ctx,
|
||||||
`insert into screen_schedules (screen_id, schedule_enabled, power_on_time, power_off_time)
|
`insert into screen_schedules (screen_id, schedule_enabled, power_on_time, power_off_time)
|
||||||
|
|
@ -681,7 +684,7 @@ func (s *ScreenScheduleStore) Upsert(ctx context.Context, sc *ScreenSchedule) er
|
||||||
// ListEnabled gibt alle Screens mit aktivem Zeitplan zurück.
|
// ListEnabled gibt alle Screens mit aktivem Zeitplan zurück.
|
||||||
func (s *ScreenScheduleStore) ListEnabled(ctx context.Context) ([]*ScreenSchedule, error) {
|
func (s *ScreenScheduleStore) ListEnabled(ctx context.Context) ([]*ScreenSchedule, error) {
|
||||||
rows, err := s.pool.Query(ctx,
|
rows, err := s.pool.Query(ctx,
|
||||||
`select screen_id, schedule_enabled, power_on_time, power_off_time
|
`select screen_id, schedule_enabled, power_on_time, power_off_time, override_on_until
|
||||||
from screen_schedules
|
from screen_schedules
|
||||||
where schedule_enabled = true
|
where schedule_enabled = true
|
||||||
and (power_on_time != '' or power_off_time != '')`)
|
and (power_on_time != '' or power_off_time != '')`)
|
||||||
|
|
@ -692,10 +695,73 @@ func (s *ScreenScheduleStore) ListEnabled(ctx context.Context) ([]*ScreenSchedul
|
||||||
var out []*ScreenSchedule
|
var out []*ScreenSchedule
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var sc ScreenSchedule
|
var sc ScreenSchedule
|
||||||
if err := rows.Scan(&sc.ScreenID, &sc.ScheduleEnabled, &sc.PowerOnTime, &sc.PowerOffTime); err != nil {
|
if err := rows.Scan(&sc.ScreenID, &sc.ScheduleEnabled, &sc.PowerOnTime, &sc.PowerOffTime, &sc.OverrideOnUntil); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
out = append(out, &sc)
|
out = append(out, &sc)
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetOverrideOnUntil setzt oder löscht den per-Screen-Override (null = löschen).
|
||||||
|
func (s *ScreenScheduleStore) SetOverrideOnUntil(ctx context.Context, screenID string, until *time.Time) error {
|
||||||
|
_, err := s.pool.Exec(ctx,
|
||||||
|
`insert into screen_schedules (screen_id, override_on_until)
|
||||||
|
values ($1, $2)
|
||||||
|
on conflict (screen_id) do update
|
||||||
|
set override_on_until = excluded.override_on_until`,
|
||||||
|
screenID, until)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// GlobalOverrideStore
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
// GlobalOverride beschreibt einen aktiven globalen Display-Override.
|
||||||
|
type GlobalOverride struct {
|
||||||
|
Type string `json:"type"` // "on" oder "off"
|
||||||
|
Until time.Time `json:"until"`
|
||||||
|
SetAt time.Time `json:"set_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GlobalOverrideStore verwaltet den globalen Display-Override (max. 1 Zeile).
|
||||||
|
type GlobalOverrideStore struct{ pool *pgxpool.Pool }
|
||||||
|
|
||||||
|
func NewGlobalOverrideStore(pool *pgxpool.Pool) *GlobalOverrideStore {
|
||||||
|
return &GlobalOverrideStore{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get lädt den aktuellen globalen Override. Gibt nil zurück wenn keiner gesetzt ist.
|
||||||
|
func (s *GlobalOverrideStore) Get(ctx context.Context) (*GlobalOverride, error) {
|
||||||
|
var o GlobalOverride
|
||||||
|
err := s.pool.QueryRow(ctx,
|
||||||
|
`select type, until, set_at from global_override where id = 1`).
|
||||||
|
Scan(&o.Type, &o.Until, &o.SetAt)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &o, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert setzt oder überschreibt den globalen Override.
|
||||||
|
func (s *GlobalOverrideStore) Upsert(ctx context.Context, overrideType string, until time.Time) error {
|
||||||
|
_, err := s.pool.Exec(ctx,
|
||||||
|
`insert into global_override (id, type, until, set_at)
|
||||||
|
values (1, $1, $2, now())
|
||||||
|
on conflict (id) do update
|
||||||
|
set type = excluded.type,
|
||||||
|
until = excluded.until,
|
||||||
|
set_at = excluded.set_at`,
|
||||||
|
overrideType, until)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete entfernt den globalen Override.
|
||||||
|
func (s *GlobalOverrideStore) Delete(ctx context.Context) error {
|
||||||
|
_, err := s.pool.Exec(ctx, `delete from global_override where id = 1`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue