diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index c800b71..9ae8e22 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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." - "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 - 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. - 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 ### 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 - Time formatting functions use `toLocaleTimeString('de-DE')` for German locale display - 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**: - 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. + 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 - 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). @@ -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`. - 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): - 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/`. @@ -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) - `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) - 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. - 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. - **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. - 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) @@ -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);` - 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 + - Frontend must append `Z` to API strings before parsing; backend compares in UTC and returns ISO without `Z`. - **JSON Naming Convention**: - Backend uses snake_case internally (Python convention) - 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, 4) Return JSON-safe values (serialize enums and datetimes), and 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. - 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. diff --git a/README.md b/README.md index f2a434b..0930664 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,7 @@ For detailed deployment instructions, see: - Videos include a `video` payload with a stream URL and playback flags: - `video`: includes `url` (streaming endpoint) and `autoplay`, `loop`, `volume`, `muted` - Streaming endpoint supports byte-range requests (206) to enable seeking: `/api/eventmedia/stream//` + - 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 @@ -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. - `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 ``` diff --git a/TECH-CHANGELOG.md b/TECH-CHANGELOG.md index f99f688..05a9174 100644 --- a/TECH-CHANGELOG.md +++ b/TECH-CHANGELOG.md @@ -8,6 +8,13 @@ This changelog documents technical and developer-relevant changes included in pu --- ## 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//`), 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//occurrences//detach`. +- 🧰 Routes cleanup: Applied `dict_to_camel_case()` before `jsonify()` uniformly; verified Session lifecycle consistency (open/commit/close) across blueprints. - 🔄 **API Naming Convention Standardization**: - 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/` 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. - 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 + - 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:` (commit ``) +- Dashboard: image tag `ghcr.io/robbstarkaustria/dashboard:` (commit ``) +- Scheduler: image tag `ghcr.io/robbstarkaustria/scheduler:` (commit ``) +- Listener: image tag `ghcr.io/robbstarkaustria/listener:` (commit ``) +- Worker: image tag `ghcr.io/robbstarkaustria/worker:` (commit ``) +``` + +This is informational (build metadata) and does not change the user-facing version number. ## 2025.1.0-alpha.11 (2025-11-05) - 🗃️ Data model & API: diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 8ddbfa1..5b97963 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -98,6 +98,7 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -385,6 +386,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -408,6 +410,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -2052,6 +2055,7 @@ "integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -2125,6 +2129,7 @@ "integrity": "sha512-B7RIQiTsCBBmY+yW4+ILd6mF5h1FUwJsVvpqkrgpszYifetQ2Ke+Z4u6aZh0CblkUGIdR59iYVyXqqZGkZ3aBw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.43.0", "@typescript-eslint/types": "8.43.0", @@ -2357,6 +2362,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2712,6 +2718,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001737", "electron-to-chromium": "^1.5.211", @@ -3475,6 +3482,7 @@ "integrity": "sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3536,6 +3544,7 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -4990,18 +4999,6 @@ "@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": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -5717,6 +5714,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -5783,6 +5781,7 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -5871,6 +5870,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -5880,6 +5880,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -6784,6 +6785,7 @@ "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -7022,6 +7024,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -7152,6 +7155,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7257,6 +7261,7 @@ "integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -7350,6 +7355,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -7580,21 +7586,6 @@ "dev": true, "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": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", diff --git a/dashboard/src/apiSystemSettings.ts b/dashboard/src/apiSystemSettings.ts index f0eb493..07da30b 100644 --- a/dashboard/src/apiSystemSettings.ts +++ b/dashboard/src/apiSystemSettings.ts @@ -106,3 +106,34 @@ export async function updateSupplementTableSettings( } 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(); +} diff --git a/dashboard/src/dashboard.tsx b/dashboard/src/dashboard.tsx index 48cd898..a84c5ae 100644 --- a/dashboard/src/dashboard.tsx +++ b/dashboard/src/dashboard.tsx @@ -3,7 +3,10 @@ import { fetchGroupsWithClients, restartClient } from './apiClients'; import type { Group, Client } from './apiClients'; import { fetchEvents } from './apiEvents'; 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 @@ -31,6 +34,15 @@ const Dashboard: React.FC = () => { const [activeEvents, setActiveEvents] = useState({}); const toastRef = React.useRef(null); + // Holiday status state + const [holidayBannerEnabled, setHolidayBannerEnabled] = useState(true); + const [activePeriod, setActivePeriod] = useState(null); + const [holidayOverlapCount, setHolidayOverlapCount] = useState(0); + const [holidayFirst, setHolidayFirst] = useState(null); + const [holidayLast, setHolidayLast] = useState(null); + const [holidayLoading, setHolidayLoading] = useState(false); + const [holidayError, setHolidayError] = useState(null); + // Optimiertes Update: Nur bei echten Datenänderungen wird das Grid aktualisiert useEffect(() => { let lastGroups: Group[] = []; @@ -72,6 +84,62 @@ const Dashboard: React.FC = () => { 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 const fetchActiveEventsForGroups = async (groupsList: Group[]) => { 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 ( + + Lade Ferienstatus ... + + ); + } + if (holidayError) { + return ( + + Fehler beim Laden des Ferienstatus: {holidayError} + + ); + } + if (!activePeriod) { + return ( + + ⚠️ Keine aktive akademische Periode gesetzt – Ferienplan nicht verknüpft. + + ); + } + if (holidayOverlapCount > 0) { + return ( + + ✅ Ferienplan vorhanden für {activePeriod.display_name || activePeriod.name}: {holidayOverlapCount} Zeitraum{holidayOverlapCount === 1 ? '' : 'e'} + {holidayFirst && holidayLast && ( + <> ({formatDate(holidayFirst)} – {formatDate(holidayLast)}) + )} + + ); + } + return ( + + ⚠️ Kein Ferienplan für {activePeriod.display_name || activePeriod.name} importiert. Jetzt unter Einstellungen → 📅 Kalender hochladen. + + ); + }; + return (
{

+ {/* Holiday Status Banner */} + {holidayBannerEnabled && ( +
+ +
+ )} + {/* Global Statistics Summary */} {(() => { const globalStats = getGlobalStats(); diff --git a/dashboard/src/settings.tsx b/dashboard/src/settings.tsx index a84f4dd..94ceccd 100644 --- a/dashboard/src/settings.tsx +++ b/dashboard/src/settings.tsx @@ -4,7 +4,7 @@ import { NumericTextBoxComponent, TextBoxComponent } from '@syncfusion/ej2-react import { ButtonComponent, CheckBoxComponent } from '@syncfusion/ej2-react-buttons'; import { ToastComponent } from '@syncfusion/ej2-react-notifications'; import { listHolidays, uploadHolidaysCsv, type Holiday } from './apiHolidays'; -import { getSupplementTableSettings, updateSupplementTableSettings } from './apiSystemSettings'; +import { getSupplementTableSettings, updateSupplementTableSettings, getHolidayBannerSetting, updateHolidayBannerSetting } from './apiSystemSettings'; import { useAuth } from './useAuth'; import { DropDownListComponent } from '@syncfusion/ej2-react-dropdowns'; import { listAcademicPeriods, getActiveAcademicPeriod, setActiveAcademicPeriod, type AcademicPeriod } from './apiAcademicPeriods'; @@ -77,6 +77,10 @@ const Einstellungen: React.FC = () => { const [supplementEnabled, setSupplementEnabled] = 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+) const [videoAutoplay, setVideoAutoplay] = React.useState(true); const [videoLoop, setVideoLoop] = React.useState(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) const loadVideoSettings = React.useCallback(async () => { try { @@ -156,6 +169,7 @@ const Einstellungen: React.FC = () => { React.useEffect(() => { refresh(); + loadHolidayBannerSetting(); // Everyone can see this if (user) { // Academic periods for all users loadAcademicPeriods(); @@ -166,7 +180,7 @@ const Einstellungen: React.FC = () => { loadVideoSettings(); } } - }, [refresh, loadSupplementSettings, loadAcademicPeriods, loadPresentationSettings, loadVideoSettings, user]); + }, [refresh, loadSupplementSettings, loadAcademicPeriods, loadPresentationSettings, loadVideoSettings, loadHolidayBannerSetting, user]); const onUpload = async () => { 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 () => { setVideoBusy(true); try { @@ -291,6 +318,34 @@ const Einstellungen: React.FC = () => { )}
+ + {/* Dashboard Display Settings Card */} +
+
+
+
Dashboard-Anzeige
+
+
+
+
+ setHolidayBannerEnabled(e.checked || false)} + /> +
+ Zeigt eine Information an, ob ein Ferienplan für die aktive Periode importiert wurde. +
+
+ + {holidayBannerBusy ? 'Speichere…' : 'Einstellung speichern'} + +
+
); diff --git a/listener/listener.py b/listener/listener.py index ce104b1..2cf05e7 100644 --- a/listener/listener.py +++ b/listener/listener.py @@ -26,6 +26,17 @@ engine = create_engine(DB_URL) 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): topic = msg.topic logging.debug(f"Empfangene Nachricht auf Topic: {topic}") @@ -87,14 +98,14 @@ def on_message(client, userdata, msg): 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_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.subscribe("infoscreen/discovery") - mqtt_client.subscribe("infoscreen/+/heartbeat") - logging.info( - "Listener gestartet und abonniert auf infoscreen/discovery und infoscreen/+/heartbeat") + logging.info("Listener gestartet; warte auf MQTT-Verbindung und Nachrichten") mqtt_client.loop_forever() diff --git a/server/Dockerfile.dev b/server/Dockerfile.dev index 0c7220f..04f63af 100644 --- a/server/Dockerfile.dev +++ b/server/Dockerfile.dev @@ -9,7 +9,7 @@ FROM python:3.13-slim # verbindet (gemäß devcontainer.json). Sie schaden aber nicht. ARG USER_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 \ && 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 \ diff --git a/server/database.py b/server/database.py index ae83f31..9d181ce 100644 --- a/server/database.py +++ b/server/database.py @@ -14,7 +14,9 @@ if not DB_URL: # Dev: DB-URL aus Einzelwerten bauen DB_USER = os.getenv("DB_USER", "infoscreen_admin") 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_URL = f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}/{DB_NAME}" diff --git a/server/init_defaults.py b/server/init_defaults.py index b783135..ea5e92a 100644 --- a/server/init_defaults.py +++ b/server/init_defaults.py @@ -3,10 +3,22 @@ import os from dotenv import load_dotenv import bcrypt -# .env laden -load_dotenv() +# .env laden (nur in Dev) +if os.getenv("ENV", "development") == "development": + 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") with engine.connect() as conn: @@ -53,6 +65,7 @@ with engine.connect() as conn: ('video_autoplay', 'true', 'Autoplay (automatisches Abspielen) 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)'), + ('holiday_banner_enabled', 'true', 'Ferienstatus-Banner auf Dashboard anzeigen'), ] for key, value, description in default_settings: diff --git a/server/routes/groups.py b/server/routes/groups.py index 4f31e15..a2b1732 100644 --- a/server/routes/groups.py +++ b/server/routes/groups.py @@ -8,7 +8,7 @@ from server.permissions import admin_or_higher, require_role from sqlalchemy import func import sys import os -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone sys.path.append('/workspace') @@ -27,6 +27,14 @@ def get_grace_period(): 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): """Berechnet, ob ein Client als alive gilt.""" if not last_alive or not is_active: @@ -42,7 +50,10 @@ def is_client_alive(last_alive, is_active): return False else: 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"]) diff --git a/server/routes/system_settings.py b/server/routes/system_settings.py index 646475d..64db2e4 100644 --- a/server/routes/system_settings.py +++ b/server/routes/system_settings.py @@ -202,3 +202,66 @@ def update_supplement_table_settings(): finally: 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() +