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:
19
.github/copilot-instructions.md
vendored
19
.github/copilot-instructions.md
vendored
@@ -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 today’s 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 today’s 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 (20–50 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.
|
||||||
|
|||||||
13
README.md
13
README.md
@@ -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
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
45
dashboard/package-lock.json
generated
45
dashboard/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 \
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user