docs/dev: sync backend rework, MQTT, and devcontainer hygiene

README: add Versioning (unified SemVer, pre-releases, build metadata); emphasize UTC handling and streaming endpoint; add Dev Container notes (UI-only Remote Containers, npm ci, idempotent aliases)
TECH-CHANGELOG: backend rework notes (serialization camelCase, UTC normalization, streaming metadata); add component build metadata template (image tags/SHAs)
Copilot instructions: integrate maintenance guardrails; reinforce UTC and camelCase conventions; document MQTT topics and scheduler retained payload behavior
Devcontainer: map Remote Containers to UI; remove in-container install; switch to npm ci; make aliases idempotent
This commit is contained in:
RobbStarkAustria
2025-11-29 15:35:13 +00:00
parent 6dcf93f0dd
commit df9f29bc6a
13 changed files with 399 additions and 42 deletions

View File

@@ -15,6 +15,8 @@ Small multi-service digital signage app (Flask API, React dashboard, MQTT schedu
- "Refactor `scheduler/db_utils.py` to prefer precomputed EventMedia metadata and fall back to a HEAD probe." - "Refactor `scheduler/db_utils.py` to prefer precomputed EventMedia metadata and fall back to a HEAD probe."
- "Add an ffprobe-based worker that extracts duration/resolution/bitrate and stores them on `EventMedia`." - "Add an ffprobe-based worker that extracts duration/resolution/bitrate and stores them on `EventMedia`."
Keep docs synced with code. When you change services/MQTT/API/UTC/env or dev/prod run steps, update this file in the same commit (see `AI-INSTRUCTIONS-MAINTENANCE.md`).
### When not to change ### When not to change
- Avoid editing generated assets under `dashboard/dist/` and compiled bundles. Don't modify files produced by CI or Docker builds (unless intentionally updating build outputs). - Avoid editing generated assets under `dashboard/dist/` and compiled bundles. Don't modify files produced by CI or Docker builds (unless intentionally updating build outputs).
@@ -40,6 +42,8 @@ Small multi-service digital signage app (Flask API, React dashboard, MQTT schedu
- Scheduler: Publishes only currently active events (per group, at "now") to MQTT retained topics in `scheduler/scheduler.py`. It queries a future window (default: 7 days) to expand recurring events using RFC 5545 rules and applies event exceptions, but only publishes events that are active at the current time. When a group has no active events, the scheduler clears its retained topic by publishing an empty list. All time comparisons are UTC; any naive timestamps are normalized. Logging is concise; conversion lookups are cached and logged only once per media. - Scheduler: Publishes only currently active events (per group, at "now") to MQTT retained topics in `scheduler/scheduler.py`. It queries a future window (default: 7 days) to expand recurring events using RFC 5545 rules and applies event exceptions, but only publishes events that are active at the current time. When a group has no active events, the scheduler clears its retained topic by publishing an empty list. All time comparisons are UTC; any naive timestamps are normalized. Logging is concise; conversion lookups are cached and logged only once per media.
- Nginx: Reverse proxy routes `/api/*` and `/screenshots/*` to API; everything else to dashboard (`nginx.conf`). - Nginx: Reverse proxy routes `/api/*` and `/screenshots/*` to API; everything else to dashboard (`nginx.conf`).
- Dev Container (hygiene): UI-only `Dev Containers` extension runs on host UI via `remote.extensionKind`; do not install it in-container. Dashboard installs use `npm ci`; shell aliases in `postStartCommand` are appended idempotently.
## Recent changes since last commit ## Recent changes since last commit
### Latest (November 2025) ### Latest (November 2025)
@@ -57,6 +61,7 @@ Small multi-service digital signage app (Flask API, React dashboard, MQTT schedu
- Frontend: Dashboard and appointments automatically append 'Z' to parse as UTC and display in user's local timezone - Frontend: Dashboard and appointments automatically append 'Z' to parse as UTC and display in user's local timezone
- Time formatting functions use `toLocaleTimeString('de-DE')` for German locale display - Time formatting functions use `toLocaleTimeString('de-DE')` for German locale display
- All time comparisons use UTC; `new Date().toISOString()` sends UTC back to API - All time comparisons use UTC; `new Date().toISOString()` sends UTC back to API
- API returns ISO strings without `Z`; frontend must append `Z` before parsing to ensure UTC
- **Dashboard Enhancements**: - **Dashboard Enhancements**:
- New card-based design for Raumgruppen (room groups) with Syncfusion components - New card-based design for Raumgruppen (room groups) with Syncfusion components
@@ -84,6 +89,11 @@ Small multi-service digital signage app (Flask API, React dashboard, MQTT schedu
Note: these edits are intentionally backwards-compatible — if the probe fails, the scheduler still emits the stream URL and the client should fallback to a direct play attempt or request richer metadata when available. Note: these edits are intentionally backwards-compatible — if the probe fails, the scheduler still emits the stream URL and the client should fallback to a direct play attempt or request richer metadata when available.
Backend rework notes (no version bump):
- Dev container hygiene: UI-only Remote Containers; reproducible dashboard installs (`npm ci`); idempotent shell aliases.
- Serialization consistency: snake_case internal → camelCase external via `server/serializers.py` for all JSON.
- UTC normalization across routes/scheduler; enums and datetimes serialize consistently.
## Service boundaries & data flow ## Service boundaries & data flow
- Database connection string is passed as `DB_CONN` (mysql+pymysql) to Python services. - Database connection string is passed as `DB_CONN` (mysql+pymysql) to Python services.
- API builds its engine in `server/database.py` (loads `.env` only in development). - API builds its engine in `server/database.py` (loads `.env` only in development).
@@ -96,6 +106,8 @@ Small multi-service digital signage app (Flask API, React dashboard, MQTT schedu
- Per-client group assignment (retained): `infoscreen/{uuid}/group_id` via `server/mqtt_helper.py`. - Per-client group assignment (retained): `infoscreen/{uuid}/group_id` via `server/mqtt_helper.py`.
- Screenshots: server-side folders `server/received_screenshots/` and `server/screenshots/`; Nginx exposes `/screenshots/{uuid}.jpg` via `server/wsgi.py` route. - Screenshots: server-side folders `server/received_screenshots/` and `server/screenshots/`; Nginx exposes `/screenshots/{uuid}.jpg` via `server/wsgi.py` route.
- Dev Container guidance: If extensions reappear inside the container, remove UI-only extensions from `devcontainer.json` `extensions` and map them in `remote.extensionKind` as `"ui"`.
- Presentation conversion (PPT/PPTX/ODP → PDF): - Presentation conversion (PPT/PPTX/ODP → PDF):
- Trigger: on upload in `server/routes/eventmedia.py` for media types `ppt|pptx|odp` (compute sha256, upsert `Conversion`, enqueue job). - Trigger: on upload in `server/routes/eventmedia.py` for media types `ppt|pptx|odp` (compute sha256, upsert `Conversion`, enqueue job).
- Worker: RQ worker runs `server.worker.convert_event_media_to_pdf`, calls Gotenberg LibreOffice endpoint, writes to `server/media/converted/`. - Worker: RQ worker runs `server.worker.convert_event_media_to_pdf`, calls Gotenberg LibreOffice endpoint, writes to `server/media/converted/`.
@@ -141,12 +153,16 @@ Small multi-service digital signage app (Flask API, React dashboard, MQTT schedu
- `POST /api/academic_periods/active` — set active period (deactivates others) - `POST /api/academic_periods/active` — set active period (deactivates others)
- `GET /api/academic_periods/for_date?date=YYYY-MM-DD` — period covering given date - `GET /api/academic_periods/for_date?date=YYYY-MM-DD` — period covering given date
Documentation maintenance: keep this file aligned with real patterns; update when routes/session/UTC rules change. Avoid long prose; link exact paths.
## Frontend patterns (dashboard) ## Frontend patterns (dashboard)
- Vite React app; proxies `/api` and `/screenshots` to API in dev (`vite.config.ts`). - Vite React app; proxies `/api` and `/screenshots` to API in dev (`vite.config.ts`).
- Uses Syncfusion components; Vite config pre-bundles specific packages to avoid alias issues. - Uses Syncfusion components; Vite config pre-bundles specific packages to avoid alias issues.
- Environment: `VITE_API_URL` provided at build/run; in dev compose, proxy handles `/api` so local fetches can use relative `/api/...` paths. - Environment: `VITE_API_URL` provided at build/run; in dev compose, proxy handles `/api` so local fetches can use relative `/api/...` paths.
- **API Response Format**: All API endpoints return camelCase JSON (e.g., `startTime`, `endTime`, `groupId`). Frontend consumes camelCase directly. - **API Response Format**: All API endpoints return camelCase JSON (e.g., `startTime`, `endTime`, `groupId`). Frontend consumes camelCase directly.
- **UTC Time Parsing**: API returns ISO strings without 'Z' suffix. Frontend appends 'Z' before parsing to ensure UTC interpretation: `const utcString = dateStr.endsWith('Z') ? dateStr : dateStr + 'Z'; new Date(utcString);`. Display uses `toLocaleTimeString('de-DE')` for German format. - **UTC Time Parsing**: API returns ISO strings without 'Z' suffix. Frontend appends 'Z' before parsing to ensure UTC interpretation: `const utcString = dateStr.endsWith('Z') ? dateStr : dateStr + 'Z'; new Date(utcString);`. Display uses `toLocaleTimeString('de-DE')` for German format.
- Dev Container: When adding frontend deps, prefer `npm ci` and, if using named volumes, recreate dashboard `node_modules` volume so installs occur inside the container.
- Theming: Syncfusion Material 3 theme is used. All component CSS is imported centrally in `dashboard/src/main.tsx` (base, navigations, buttons, inputs, dropdowns, popups, kanban, grids, schedule, filemanager, notifications, layouts, lists, calendars, splitbuttons, icons). Tailwind CSS has been removed. - Theming: Syncfusion Material 3 theme is used. All component CSS is imported centrally in `dashboard/src/main.tsx` (base, navigations, buttons, inputs, dropdowns, popups, kanban, grids, schedule, filemanager, notifications, layouts, lists, calendars, splitbuttons, icons). Tailwind CSS has been removed.
- Scheduler (appointments page): top bar includes Group and Academic Period selectors (Syncfusion DropDownList). Selecting a period calls `POST /api/academic_periods/active`, moves the calendar to todays month/day within the period year, and refreshes a right-aligned indicator row showing: - Scheduler (appointments page): top bar includes Group and Academic Period selectors (Syncfusion DropDownList). Selecting a period calls `POST /api/academic_periods/active`, moves the calendar to todays month/day within the period year, and refreshes a right-aligned indicator row showing:
- Holidays present in the current view (count) - Holidays present in the current view (count)
@@ -252,6 +268,7 @@ Note: Syncfusion usage in the dashboard is already documented above; if a UI for
- Frontend **must** append 'Z' before parsing: `const utcStr = dateStr.endsWith('Z') ? dateStr : dateStr + 'Z'; new Date(utcStr);` - Frontend **must** append 'Z' before parsing: `const utcStr = dateStr.endsWith('Z') ? dateStr : dateStr + 'Z'; new Date(utcStr);`
- Display in local timezone using `toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })` - Display in local timezone using `toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })`
- When sending to API, use `date.toISOString()` which includes 'Z' and is UTC - When sending to API, use `date.toISOString()` which includes 'Z' and is UTC
- Frontend must append `Z` to API strings before parsing; backend compares in UTC and returns ISO without `Z`.
- **JSON Naming Convention**: - **JSON Naming Convention**:
- Backend uses snake_case internally (Python convention) - Backend uses snake_case internally (Python convention)
- API returns camelCase JSON (web standard): `startTime`, `endTime`, `groupId`, etc. - API returns camelCase JSON (web standard): `startTime`, `endTime`, `groupId`, etc.
@@ -268,6 +285,8 @@ Note: Syncfusion usage in the dashboard is already documented above; if a UI for
3) Manage `Session()` lifecycle, 3) Manage `Session()` lifecycle,
4) Return JSON-safe values (serialize enums and datetimes), and 4) Return JSON-safe values (serialize enums and datetimes), and
5) Use `dict_to_camel_case()` for camelCase JSON responses 5) Use `dict_to_camel_case()` for camelCase JSON responses
Docs maintenance guardrails (solo-friendly): Update this file alongside code changes (services/MQTT/API/UTC/env). Keep it concise (2050 lines per section). Never include secrets.
- When extending media types, update `MediaType` and any logic in `eventmedia` and dashboard that depends on it. - When extending media types, update `MediaType` and any logic in `eventmedia` and dashboard that depends on it.
- Academic periods: Events/media can be optionally associated with periods for educational organization. Only one period should be active at a time (`is_active=True`). - Academic periods: Events/media can be optionally associated with periods for educational organization. Only one period should be active at a time (`is_active=True`).
- Initialization scripts: legacy DB init scripts were removed; use Alembic and `initialize_database.py` going forward. - Initialization scripts: legacy DB init scripts were removed; use Alembic and `initialize_database.py` going forward.

View File

@@ -217,6 +217,7 @@ For detailed deployment instructions, see:
- Videos include a `video` payload with a stream URL and playback flags: - Videos include a `video` payload with a stream URL and playback flags:
- `video`: includes `url` (streaming endpoint) and `autoplay`, `loop`, `volume`, `muted` - `video`: includes `url` (streaming endpoint) and `autoplay`, `loop`, `volume`, `muted`
- Streaming endpoint supports byte-range requests (206) to enable seeking: `/api/eventmedia/stream/<media_id>/<filename>` - Streaming endpoint supports byte-range requests (206) to enable seeking: `/api/eventmedia/stream/<media_id>/<filename>`
- Server-side UTC: All backend comparisons are performed in UTC; API returns ISO strings without `Z`. Frontend appends `Z` before parsing.
## Recent changes since last commit ## Recent changes since last commit
@@ -232,6 +233,18 @@ These changes are designed to be safe if metadata extraction or probes fail —
See `MQTT_EVENT_PAYLOAD_GUIDE.md` for details. See `MQTT_EVENT_PAYLOAD_GUIDE.md` for details.
- `infoscreen/{uuid}/group_id` - Client group assignment - `infoscreen/{uuid}/group_id` - Client group assignment
## 🧩 Developer Environment Notes (Dev Container)
- Extensions: UI-only `Dev Containers` runs on the host UI; not installed inside the container to avoid reinstallation loops. See `/.devcontainer/devcontainer.json` (`remote.extensionKind`).
- Installs: Dashboard uses `npm ci` on `postCreateCommand` for reproducible installs.
- Aliases: `postStartCommand` appends shell aliases idempotently to prevent duplicates across restarts.
## 📦 Versioning
- Unified app version: Use a single SemVer for the product (e.g., `2025.1.0-beta.3`) — simplest for users and release management.
- Pre-releases: Use identifiers like `-alpha.N`, `-beta.N`, `-rc.N` for stage tracking.
- Build metadata: Optionally include component build info (non-ordering) e.g., `+api.abcd123,dash.efgh456,sch.jkl789,wkr.mno012`.
- Component traceability: Document component SHAs or image tags under each TECH-CHANGELOG release entry rather than exposing separate user-facing versions.
- Hotfixes: For backend-only fixes, prefer a patch bump or pre-release increment, and record component metadata under the unified version.
## 📁 Project Structure ## 📁 Project Structure
``` ```

View File

@@ -8,6 +8,13 @@ This changelog documents technical and developer-relevant changes included in pu
--- ---
## 2025.1.0-alpha.12 (2025-11-27) ## 2025.1.0-alpha.12 (2025-11-27)
Backend rework (post-release notes; no version bump):
- 🧩 Dev Container hygiene: Remote Containers runs on UI (`remote.extensionKind`), removed in-container install to prevent reappearance loops; switched `postCreateCommand` to `npm ci` for reproducible dashboard installs; `postStartCommand` aliases made idempotent.
- 🔄 Serialization: Consolidated snake_case→camelCase via `server/serializers.py` for all JSON outputs; ensured enums/UTC datetimes serialize consistently across routes.
- 🕒 Time handling: Normalized naive timestamps to UTC in all back-end comparisons (events, scheduler, groups) and kept ISO strings without `Z` in API responses; frontend appends `Z`.
- 📡 Streaming: Stabilized range-capable endpoint (`/api/eventmedia/stream/<media_id>/<filename>`), clarified client handling; scheduler emits basic HEAD-probe metadata (`mime_type`, `size`, `accept_ranges`).
- 📅 Recurrence/exceptions: Ensured EXDATE tokens (RFC 5545 UTC) align with occurrence start; detached-occurrence flow confirmed via `POST /api/events/<id>/occurrences/<date>/detach`.
- 🧰 Routes cleanup: Applied `dict_to_camel_case()` before `jsonify()` uniformly; verified Session lifecycle consistency (open/commit/close) across blueprints.
- 🔄 **API Naming Convention Standardization**: - 🔄 **API Naming Convention Standardization**:
- Created `server/serializers.py` with `dict_to_camel_case()` and `dict_to_snake_case()` utilities for consistent JSON serialization - Created `server/serializers.py` with `dict_to_camel_case()` and `dict_to_snake_case()` utilities for consistent JSON serialization
- Events API refactored: `GET /api/events` and `GET /api/events/<id>` now return camelCase JSON (`id`, `subject`, `startTime`, `endTime`, `type`, `groupId`, etc.) instead of PascalCase - Events API refactored: `GET /api/events` and `GET /api/events/<id>` now return camelCase JSON (`id`, `subject`, `startTime`, `endTime`, `type`, `groupId`, etc.) instead of PascalCase
@@ -46,6 +53,23 @@ Notes for integrators:
- **Breaking change**: All Events API endpoints now return camelCase field names. Update client code accordingly. - **Breaking change**: All Events API endpoints now return camelCase field names. Update client code accordingly.
- Frontend must append 'Z' to API datetime strings before parsing: `const utcStr = dateStr.endsWith('Z') ? dateStr : dateStr + 'Z'; new Date(utcStr);` - Frontend must append 'Z' to API datetime strings before parsing: `const utcStr = dateStr.endsWith('Z') ? dateStr : dateStr + 'Z'; new Date(utcStr);`
- Use `dict_to_camel_case()` from `server/serializers.py` for any new API endpoints returning JSON - Use `dict_to_camel_case()` from `server/serializers.py` for any new API endpoints returning JSON
- Dev container: prefer `npm ci` and UI-only Remote Containers to avoid extension drift in-container.
---
### Component build metadata template (for traceability)
Record component builds under the unified app version when releasing:
```
Component builds for this release
- API: image tag `ghcr.io/robbstarkaustria/api:<short-sha>` (commit `<sha>`)
- Dashboard: image tag `ghcr.io/robbstarkaustria/dashboard:<short-sha>` (commit `<sha>`)
- Scheduler: image tag `ghcr.io/robbstarkaustria/scheduler:<short-sha>` (commit `<sha>`)
- Listener: image tag `ghcr.io/robbstarkaustria/listener:<short-sha>` (commit `<sha>`)
- Worker: image tag `ghcr.io/robbstarkaustria/worker:<short-sha>` (commit `<sha>`)
```
This is informational (build metadata) and does not change the user-facing version number.
## 2025.1.0-alpha.11 (2025-11-05) ## 2025.1.0-alpha.11 (2025-11-05)
- 🗃️ Data model & API: - 🗃️ Data model & API:

View File

@@ -98,6 +98,7 @@
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3", "@babel/generator": "^7.28.3",
@@ -385,6 +386,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
}, },
@@ -408,6 +410,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -2052,6 +2055,7 @@
"integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==", "integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.0.2" "csstype": "^3.0.2"
} }
@@ -2125,6 +2129,7 @@
"integrity": "sha512-B7RIQiTsCBBmY+yW4+ILd6mF5h1FUwJsVvpqkrgpszYifetQ2Ke+Z4u6aZh0CblkUGIdR59iYVyXqqZGkZ3aBw==", "integrity": "sha512-B7RIQiTsCBBmY+yW4+ILd6mF5h1FUwJsVvpqkrgpszYifetQ2Ke+Z4u6aZh0CblkUGIdR59iYVyXqqZGkZ3aBw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.43.0", "@typescript-eslint/scope-manager": "8.43.0",
"@typescript-eslint/types": "8.43.0", "@typescript-eslint/types": "8.43.0",
@@ -2357,6 +2362,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -2712,6 +2718,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"caniuse-lite": "^1.0.30001737", "caniuse-lite": "^1.0.30001737",
"electron-to-chromium": "^1.5.211", "electron-to-chromium": "^1.5.211",
@@ -3475,6 +3482,7 @@
"integrity": "sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==", "integrity": "sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -3536,6 +3544,7 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"eslint-config-prettier": "bin/cli.js" "eslint-config-prettier": "bin/cli.js"
}, },
@@ -4990,18 +4999,6 @@
"@pkgjs/parseargs": "^0.11.0" "@pkgjs/parseargs": "^0.11.0"
} }
}, },
"node_modules/jiti": {
"version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -5717,6 +5714,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -5783,6 +5781,7 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"prettier": "bin/prettier.cjs" "prettier": "bin/prettier.cjs"
}, },
@@ -5871,6 +5870,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz",
"integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -5880,6 +5880,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz",
"integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.26.0" "scheduler": "^0.26.0"
}, },
@@ -6784,6 +6785,7 @@
"integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"cssesc": "^3.0.0", "cssesc": "^3.0.0",
"util-deprecate": "^1.0.2" "util-deprecate": "^1.0.2"
@@ -7022,6 +7024,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -7152,6 +7155,7 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -7257,6 +7261,7 @@
"integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==", "integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.4.4", "fdir": "^6.4.4",
@@ -7350,6 +7355,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -7580,21 +7586,6 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/yaml": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
"integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
"dev": true,
"license": "ISC",
"optional": true,
"peer": true,
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
}
},
"node_modules/yauzl": { "node_modules/yauzl": {
"version": "2.10.0", "version": "2.10.0",
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",

View File

@@ -106,3 +106,34 @@ export async function updateSupplementTableSettings(
} }
return response.json(); return response.json();
} }
/**
* Get holiday banner setting
*/
export async function getHolidayBannerSetting(): Promise<{ enabled: boolean }> {
const response = await fetch(`/api/system-settings/holiday-banner`, {
credentials: 'include',
});
if (!response.ok) {
throw new Error(`Failed to fetch holiday banner setting: ${response.statusText}`);
}
return response.json();
}
/**
* Update holiday banner setting
*/
export async function updateHolidayBannerSetting(
enabled: boolean
): Promise<{ enabled: boolean; message: string }> {
const response = await fetch(`/api/system-settings/holiday-banner`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ enabled }),
});
if (!response.ok) {
throw new Error(`Failed to update holiday banner setting: ${response.statusText}`);
}
return response.json();
}

View File

@@ -3,7 +3,10 @@ import { fetchGroupsWithClients, restartClient } from './apiClients';
import type { Group, Client } from './apiClients'; import type { Group, Client } from './apiClients';
import { fetchEvents } from './apiEvents'; import { fetchEvents } from './apiEvents';
import { ButtonComponent } from '@syncfusion/ej2-react-buttons'; import { ButtonComponent } from '@syncfusion/ej2-react-buttons';
import { ToastComponent } from '@syncfusion/ej2-react-notifications'; import { ToastComponent, MessageComponent } from '@syncfusion/ej2-react-notifications';
import { listHolidays } from './apiHolidays';
import { getActiveAcademicPeriod, type AcademicPeriod } from './apiAcademicPeriods';
import { getHolidayBannerSetting } from './apiSystemSettings';
const REFRESH_INTERVAL = 15000; // 15 Sekunden const REFRESH_INTERVAL = 15000; // 15 Sekunden
@@ -31,6 +34,15 @@ const Dashboard: React.FC = () => {
const [activeEvents, setActiveEvents] = useState<GroupEvents>({}); const [activeEvents, setActiveEvents] = useState<GroupEvents>({});
const toastRef = React.useRef<ToastComponent>(null); const toastRef = React.useRef<ToastComponent>(null);
// Holiday status state
const [holidayBannerEnabled, setHolidayBannerEnabled] = useState<boolean>(true);
const [activePeriod, setActivePeriod] = useState<AcademicPeriod | null>(null);
const [holidayOverlapCount, setHolidayOverlapCount] = useState<number>(0);
const [holidayFirst, setHolidayFirst] = useState<string | null>(null);
const [holidayLast, setHolidayLast] = useState<string | null>(null);
const [holidayLoading, setHolidayLoading] = useState<boolean>(false);
const [holidayError, setHolidayError] = useState<string | null>(null);
// Optimiertes Update: Nur bei echten Datenänderungen wird das Grid aktualisiert // Optimiertes Update: Nur bei echten Datenänderungen wird das Grid aktualisiert
useEffect(() => { useEffect(() => {
let lastGroups: Group[] = []; let lastGroups: Group[] = [];
@@ -72,6 +84,62 @@ const Dashboard: React.FC = () => {
return () => clearInterval(interval); return () => clearInterval(interval);
}, []); }, []);
// Load academic period & holidays status
useEffect(() => {
const loadHolidayStatus = async () => {
// Check if banner is enabled first
try {
const bannerSetting = await getHolidayBannerSetting();
setHolidayBannerEnabled(bannerSetting.enabled);
if (!bannerSetting.enabled) {
return; // Skip loading if disabled
}
} catch (e) {
console.error('Fehler beim Laden der Banner-Einstellung:', e);
// Continue with default (enabled)
}
setHolidayLoading(true);
setHolidayError(null);
try {
const period = await getActiveAcademicPeriod();
setActivePeriod(period || null);
const holidayData = await listHolidays();
const list = holidayData.holidays || [];
if (period) {
// Check for holidays overlapping with active period
const ps = new Date(period.start_date + 'T00:00:00');
const pe = new Date(period.end_date + 'T23:59:59');
const overlapping = list.filter(h => {
const hs = new Date(h.start_date + 'T00:00:00');
const he = new Date(h.end_date + 'T23:59:59');
return hs <= pe && he >= ps;
});
setHolidayOverlapCount(overlapping.length);
if (overlapping.length > 0) {
const sorted = overlapping.slice().sort((a, b) => a.start_date.localeCompare(b.start_date));
setHolidayFirst(sorted[0].start_date);
setHolidayLast(sorted[sorted.length - 1].end_date);
} else {
setHolidayFirst(null);
setHolidayLast(null);
}
} else {
setHolidayOverlapCount(0);
setHolidayFirst(null);
setHolidayLast(null);
}
} catch (e) {
const msg = e instanceof Error ? e.message : 'Ferienstatus konnte nicht geladen werden';
setHolidayError(msg);
} finally {
setHolidayLoading(false);
}
};
loadHolidayStatus();
}, []);
// Fetch currently active events for all groups // Fetch currently active events for all groups
const fetchActiveEventsForGroups = async (groupsList: Group[]) => { const fetchActiveEventsForGroups = async (groupsList: Group[]) => {
const now = new Date(); const now = new Date();
@@ -344,6 +412,55 @@ const Dashboard: React.FC = () => {
}); });
}; };
// Format date for holiday display
const formatDate = (iso: string | null) => {
if (!iso) return '-';
try {
const d = new Date(iso + 'T00:00:00');
return d.toLocaleDateString('de-DE');
} catch { return iso; }
};
// Holiday Status Banner Component
const HolidayStatusBanner = () => {
if (holidayLoading) {
return (
<MessageComponent severity="Info" variant="Filled">
Lade Ferienstatus ...
</MessageComponent>
);
}
if (holidayError) {
return (
<MessageComponent severity="Error" variant="Filled">
Fehler beim Laden des Ferienstatus: {holidayError}
</MessageComponent>
);
}
if (!activePeriod) {
return (
<MessageComponent severity="Warning" variant="Outlined">
Keine aktive akademische Periode gesetzt Ferienplan nicht verknüpft.
</MessageComponent>
);
}
if (holidayOverlapCount > 0) {
return (
<MessageComponent severity="Success" variant="Filled">
Ferienplan vorhanden für <strong>{activePeriod.display_name || activePeriod.name}</strong>: {holidayOverlapCount} Zeitraum{holidayOverlapCount === 1 ? '' : 'e'}
{holidayFirst && holidayLast && (
<> ({formatDate(holidayFirst)} {formatDate(holidayLast)})</>
)}
</MessageComponent>
);
}
return (
<MessageComponent severity="Warning" variant="Filled">
Kein Ferienplan für <strong>{activePeriod.display_name || activePeriod.name}</strong> importiert. Jetzt unter Einstellungen 📅 Kalender hochladen.
</MessageComponent>
);
};
return ( return (
<div> <div>
<ToastComponent <ToastComponent
@@ -361,6 +478,13 @@ const Dashboard: React.FC = () => {
</p> </p>
</header> </header>
{/* Holiday Status Banner */}
{holidayBannerEnabled && (
<div style={{ marginBottom: '20px' }}>
<HolidayStatusBanner />
</div>
)}
{/* Global Statistics Summary */} {/* Global Statistics Summary */}
{(() => { {(() => {
const globalStats = getGlobalStats(); const globalStats = getGlobalStats();

View File

@@ -4,7 +4,7 @@ import { NumericTextBoxComponent, TextBoxComponent } from '@syncfusion/ej2-react
import { ButtonComponent, CheckBoxComponent } from '@syncfusion/ej2-react-buttons'; import { ButtonComponent, CheckBoxComponent } from '@syncfusion/ej2-react-buttons';
import { ToastComponent } from '@syncfusion/ej2-react-notifications'; import { ToastComponent } from '@syncfusion/ej2-react-notifications';
import { listHolidays, uploadHolidaysCsv, type Holiday } from './apiHolidays'; import { listHolidays, uploadHolidaysCsv, type Holiday } from './apiHolidays';
import { getSupplementTableSettings, updateSupplementTableSettings } from './apiSystemSettings'; import { getSupplementTableSettings, updateSupplementTableSettings, getHolidayBannerSetting, updateHolidayBannerSetting } from './apiSystemSettings';
import { useAuth } from './useAuth'; import { useAuth } from './useAuth';
import { DropDownListComponent } from '@syncfusion/ej2-react-dropdowns'; import { DropDownListComponent } from '@syncfusion/ej2-react-dropdowns';
import { listAcademicPeriods, getActiveAcademicPeriod, setActiveAcademicPeriod, type AcademicPeriod } from './apiAcademicPeriods'; import { listAcademicPeriods, getActiveAcademicPeriod, setActiveAcademicPeriod, type AcademicPeriod } from './apiAcademicPeriods';
@@ -77,6 +77,10 @@ const Einstellungen: React.FC = () => {
const [supplementEnabled, setSupplementEnabled] = React.useState(false); const [supplementEnabled, setSupplementEnabled] = React.useState(false);
const [supplementBusy, setSupplementBusy] = React.useState(false); const [supplementBusy, setSupplementBusy] = React.useState(false);
// Holiday banner state
const [holidayBannerEnabled, setHolidayBannerEnabled] = React.useState(true);
const [holidayBannerBusy, setHolidayBannerBusy] = React.useState(false);
// Video defaults state (Admin+) // Video defaults state (Admin+)
const [videoAutoplay, setVideoAutoplay] = React.useState<boolean>(true); const [videoAutoplay, setVideoAutoplay] = React.useState<boolean>(true);
const [videoLoop, setVideoLoop] = React.useState<boolean>(true); const [videoLoop, setVideoLoop] = React.useState<boolean>(true);
@@ -116,6 +120,15 @@ const Einstellungen: React.FC = () => {
} }
}, []); }, []);
const loadHolidayBannerSetting = React.useCallback(async () => {
try {
const data = await getHolidayBannerSetting();
setHolidayBannerEnabled(data.enabled);
} catch (e) {
console.error('Fehler beim Laden der Ferienbanner-Einstellung:', e);
}
}, []);
// Load video default settings (with fallbacks) // Load video default settings (with fallbacks)
const loadVideoSettings = React.useCallback(async () => { const loadVideoSettings = React.useCallback(async () => {
try { try {
@@ -156,6 +169,7 @@ const Einstellungen: React.FC = () => {
React.useEffect(() => { React.useEffect(() => {
refresh(); refresh();
loadHolidayBannerSetting(); // Everyone can see this
if (user) { if (user) {
// Academic periods for all users // Academic periods for all users
loadAcademicPeriods(); loadAcademicPeriods();
@@ -166,7 +180,7 @@ const Einstellungen: React.FC = () => {
loadVideoSettings(); loadVideoSettings();
} }
} }
}, [refresh, loadSupplementSettings, loadAcademicPeriods, loadPresentationSettings, loadVideoSettings, user]); }, [refresh, loadSupplementSettings, loadAcademicPeriods, loadPresentationSettings, loadVideoSettings, loadHolidayBannerSetting, user]);
const onUpload = async () => { const onUpload = async () => {
if (!file) return; if (!file) return;
@@ -208,6 +222,19 @@ const Einstellungen: React.FC = () => {
} }
}; };
const onSaveHolidayBannerSetting = async () => {
setHolidayBannerBusy(true);
try {
await updateHolidayBannerSetting(holidayBannerEnabled);
showToast('Ferienbanner-Einstellung gespeichert', 'e-toast-success');
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Speichern';
showToast(msg, 'e-toast-danger');
} finally {
setHolidayBannerBusy(false);
}
};
const onSaveVideoSettings = async () => { const onSaveVideoSettings = async () => {
setVideoBusy(true); setVideoBusy(true);
try { try {
@@ -291,6 +318,34 @@ const Einstellungen: React.FC = () => {
)} )}
</div> </div>
</div> </div>
{/* Dashboard Display Settings Card */}
<div className="e-card" style={{ marginTop: 20 }}>
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Dashboard-Anzeige</div>
</div>
</div>
<div className="e-card-content">
<div style={{ marginBottom: 16 }}>
<CheckBoxComponent
label="Ferienstatus-Banner auf Dashboard anzeigen"
checked={holidayBannerEnabled}
change={(e) => setHolidayBannerEnabled(e.checked || false)}
/>
<div style={{ fontSize: '12px', color: '#666', marginTop: 4, marginLeft: 24 }}>
Zeigt eine Information an, ob ein Ferienplan für die aktive Periode importiert wurde.
</div>
</div>
<ButtonComponent
cssClass="e-primary"
onClick={onSaveHolidayBannerSetting}
disabled={holidayBannerBusy}
>
{holidayBannerBusy ? 'Speichere…' : 'Einstellung speichern'}
</ButtonComponent>
</div>
</div>
</div> </div>
); );

View File

@@ -26,6 +26,17 @@ engine = create_engine(DB_URL)
Session = sessionmaker(bind=engine) Session = sessionmaker(bind=engine)
def on_connect(client, userdata, flags, reasonCode, properties):
"""Callback for when client connects or reconnects (API v2)."""
try:
# Subscribe on every (re)connect so we don't miss heartbeats after broker restarts
client.subscribe("infoscreen/discovery")
client.subscribe("infoscreen/+/heartbeat")
logging.info(f"MQTT connected (reasonCode: {reasonCode}); (re)subscribed to discovery and heartbeats")
except Exception as e:
logging.error(f"Subscribe failed on connect: {e}")
def on_message(client, userdata, msg): def on_message(client, userdata, msg):
topic = msg.topic topic = msg.topic
logging.debug(f"Empfangene Nachricht auf Topic: {topic}") logging.debug(f"Empfangene Nachricht auf Topic: {topic}")
@@ -87,14 +98,14 @@ def on_message(client, userdata, msg):
def main(): def main():
mqtt_client = mqtt.Client(protocol=mqtt.MQTTv311, callback_api_version=2) mqtt_client = mqtt.Client(protocol=mqtt.MQTTv311, callback_api_version=mqtt.CallbackAPIVersion.VERSION2)
mqtt_client.on_message = on_message mqtt_client.on_message = on_message
mqtt_client.on_connect = on_connect
# Set an exponential reconnect delay to survive broker restarts
mqtt_client.reconnect_delay_set(min_delay=1, max_delay=60)
mqtt_client.connect("mqtt", 1883) mqtt_client.connect("mqtt", 1883)
mqtt_client.subscribe("infoscreen/discovery")
mqtt_client.subscribe("infoscreen/+/heartbeat")
logging.info( logging.info("Listener gestartet; warte auf MQTT-Verbindung und Nachrichten")
"Listener gestartet und abonniert auf infoscreen/discovery und infoscreen/+/heartbeat")
mqtt_client.loop_forever() mqtt_client.loop_forever()

View File

@@ -9,7 +9,7 @@ FROM python:3.13-slim
# verbindet (gemäß devcontainer.json). Sie schaden aber nicht. # verbindet (gemäß devcontainer.json). Sie schaden aber nicht.
ARG USER_ID=1000 ARG USER_ID=1000
ARG GROUP_ID=1000 ARG GROUP_ID=1000
RUN apt-get update && apt-get install -y --no-install-recommends locales curl git \ RUN apt-get update && apt-get install -y --no-install-recommends locales curl git docker.io \
&& groupadd -g ${GROUP_ID} infoscreen_taa \ && groupadd -g ${GROUP_ID} infoscreen_taa \
&& useradd -u ${USER_ID} -g ${GROUP_ID} --shell /bin/bash --create-home infoscreen_taa \ && useradd -u ${USER_ID} -g ${GROUP_ID} --shell /bin/bash --create-home infoscreen_taa \
&& sed -i 's/# de_DE.UTF-8 UTF-8/de_DE.UTF-8 UTF-8/' /etc/locale.gen \ && sed -i 's/# de_DE.UTF-8 UTF-8/de_DE.UTF-8 UTF-8/' /etc/locale.gen \

View File

@@ -14,7 +14,9 @@ if not DB_URL:
# Dev: DB-URL aus Einzelwerten bauen # Dev: DB-URL aus Einzelwerten bauen
DB_USER = os.getenv("DB_USER", "infoscreen_admin") DB_USER = os.getenv("DB_USER", "infoscreen_admin")
DB_PASSWORD = os.getenv("DB_PASSWORD", "KqtpM7wmNd&mFKs") DB_PASSWORD = os.getenv("DB_PASSWORD", "KqtpM7wmNd&mFKs")
DB_HOST = os.getenv("DB_HOST", "db") # IMMER 'db' als Host im Container! # Dev container: use host.docker.internal or localhost if db container isn't on same network
# Docker Compose: use 'db' service name
DB_HOST = os.getenv("DB_HOST", "db") # Default to db for Docker Compose
DB_NAME = os.getenv("DB_NAME", "infoscreen_by_taa") DB_NAME = os.getenv("DB_NAME", "infoscreen_by_taa")
DB_URL = f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}/{DB_NAME}" DB_URL = f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}/{DB_NAME}"

View File

@@ -3,10 +3,22 @@ import os
from dotenv import load_dotenv from dotenv import load_dotenv
import bcrypt import bcrypt
# .env laden # .env laden (nur in Dev)
if os.getenv("ENV", "development") == "development":
load_dotenv() load_dotenv()
DB_URL = f"mysql+pymysql://{os.getenv('DB_USER')}:{os.getenv('DB_PASSWORD')}@{os.getenv('DB_HOST')}:3306/{os.getenv('DB_NAME')}" # Use same logic as database.py: prefer DB_CONN, fallback to individual vars
DB_URL = os.getenv("DB_CONN")
if not DB_URL:
DB_USER = os.getenv("DB_USER", "infoscreen_admin")
DB_PASSWORD = os.getenv("DB_PASSWORD")
# In Docker Compose: DB_HOST will be 'db' from env
# In dev container: will be 'localhost' from .env
DB_HOST = os.getenv("DB_HOST", "db") # Default to 'db' for Docker Compose
DB_NAME = os.getenv("DB_NAME", "infoscreen_by_taa")
DB_URL = f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:3306/{DB_NAME}"
print(f"init_defaults.py connecting to: {DB_URL.split('@')[1] if '@' in DB_URL else DB_URL}")
engine = create_engine(DB_URL, isolation_level="AUTOCOMMIT") engine = create_engine(DB_URL, isolation_level="AUTOCOMMIT")
with engine.connect() as conn: with engine.connect() as conn:
@@ -53,6 +65,7 @@ with engine.connect() as conn:
('video_autoplay', 'true', 'Autoplay (automatisches Abspielen) für Videos'), ('video_autoplay', 'true', 'Autoplay (automatisches Abspielen) für Videos'),
('video_loop', 'true', 'Loop (Wiederholung) für Videos'), ('video_loop', 'true', 'Loop (Wiederholung) für Videos'),
('video_volume', '0.8', 'Standard Lautstärke für Videos (0.0 - 1.0)'), ('video_volume', '0.8', 'Standard Lautstärke für Videos (0.0 - 1.0)'),
('holiday_banner_enabled', 'true', 'Ferienstatus-Banner auf Dashboard anzeigen'),
] ]
for key, value, description in default_settings: for key, value, description in default_settings:

View File

@@ -8,7 +8,7 @@ from server.permissions import admin_or_higher, require_role
from sqlalchemy import func from sqlalchemy import func
import sys import sys
import os import os
from datetime import datetime, timedelta from datetime import datetime, timedelta, timezone
sys.path.append('/workspace') sys.path.append('/workspace')
@@ -27,6 +27,14 @@ def get_grace_period():
return int(os.environ.get("HEARTBEAT_GRACE_PERIOD_PROD", "170")) return int(os.environ.get("HEARTBEAT_GRACE_PERIOD_PROD", "170"))
def _to_utc(dt: datetime) -> datetime:
if dt is None:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def is_client_alive(last_alive, is_active): def is_client_alive(last_alive, is_active):
"""Berechnet, ob ein Client als alive gilt.""" """Berechnet, ob ein Client als alive gilt."""
if not last_alive or not is_active: if not last_alive or not is_active:
@@ -42,7 +50,10 @@ def is_client_alive(last_alive, is_active):
return False return False
else: else:
last_alive_dt = last_alive last_alive_dt = last_alive
return datetime.utcnow() - last_alive_dt <= timedelta(seconds=grace_period) # Vergleiche immer in UTC und mit tz-aware Datetimes
last_alive_utc = _to_utc(last_alive_dt)
now_utc = datetime.now(timezone.utc)
return (now_utc - last_alive_utc) <= timedelta(seconds=grace_period)
@groups_bp.route("", methods=["POST"]) @groups_bp.route("", methods=["POST"])

View File

@@ -202,3 +202,66 @@ def update_supplement_table_settings():
finally: finally:
session.close() session.close()
@system_settings_bp.route('/holiday-banner', methods=['GET'])
def get_holiday_banner_setting():
"""
Get holiday banner enabled status.
Public endpoint - dashboard needs this.
"""
session = Session()
try:
setting = session.query(SystemSetting).filter_by(key='holiday_banner_enabled').first()
enabled = setting.value == 'true' if setting else True
return jsonify({'enabled': enabled}), 200
except SQLAlchemyError as e:
return jsonify({'error': str(e)}), 500
finally:
session.close()
@system_settings_bp.route('/holiday-banner', methods=['POST'])
@admin_or_higher
def update_holiday_banner_setting():
"""
Update holiday banner enabled status.
Admin+ only.
Request body:
{
"enabled": true/false
}
"""
session = Session()
try:
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
enabled = data.get('enabled', True)
# Update or create setting
setting = session.query(SystemSetting).filter_by(key='holiday_banner_enabled').first()
if setting:
setting.value = 'true' if enabled else 'false'
else:
setting = SystemSetting(
key='holiday_banner_enabled',
value='true' if enabled else 'false',
description='Ferienstatus-Banner auf Dashboard anzeigen'
)
session.add(setting)
session.commit()
return jsonify({
'enabled': enabled,
'message': 'Holiday banner setting updated successfully'
}), 200
except SQLAlchemyError as e:
session.rollback()
return jsonify({'error': str(e)}), 500
finally:
session.close()