diff --git a/.gitignore b/.gitignore index d0d9e50..55059a3 100644 --- a/.gitignore +++ b/.gitignore @@ -74,3 +74,4 @@ dashboard/pages/test.py dashboard/sidebar_test.py dashboard/assets/responsive-sidebar.css dashboard/src/nested_tabs.js +scheduler/scheduler.log.2 diff --git a/dashboard/public/program-info.json b/dashboard/public/program-info.json index d54038c..a2e4cc6 100644 --- a/dashboard/public/program-info.json +++ b/dashboard/public/program-info.json @@ -1,7 +1,7 @@ { "appName": "Infoscreen-Management", - "version": "2025.1.0-alpha.13", - "copyright": "© 2025 Third-Age-Applications", + "version": "2026.1.0-alpha.13", + "copyright": "© 2026 Third-Age-Applications", "supportContact": "support@third-age-applications.com", "description": "Eine zentrale Verwaltungsoberfläche für digitale Informationsbildschirme.", "techStack": { diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index b7c2334..0734a4e 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -61,6 +61,7 @@ import { useToast } from './components/ToastProvider'; const Layout: React.FC = () => { const [version, setVersion] = useState(''); const [isCollapsed, setIsCollapsed] = useState(false); + const [organizationName, setOrganizationName] = useState(''); let sidebarRef: SidebarComponent | null; const { user } = useAuth(); const toast = useToast(); @@ -80,6 +81,25 @@ const Layout: React.FC = () => { .catch(err => console.error('Failed to load version info:', err)); }, []); + // Load organization name + React.useEffect(() => { + const loadOrgName = async () => { + try { + const { getOrganizationName } = await import('./apiSystemSettings'); + const data = await getOrganizationName(); + setOrganizationName(data.name || ''); + } catch (err) { + console.error('Failed to load organization name:', err); + } + }; + loadOrgName(); + + // Listen for organization name updates from Settings page + const handleUpdate = () => loadOrgName(); + window.addEventListener('organizationNameUpdated', handleUpdate); + return () => window.removeEventListener('organizationNameUpdated', handleUpdate); + }, []); + const toggleSidebar = () => { if (sidebarRef) { sidebarRef.toggle(); @@ -346,9 +366,11 @@ const Layout: React.FC = () => { Infoscreen-Management
- - [Organisationsname] - + {organizationName && ( + + {organizationName} + + )} {user && ( { + const response = await fetch(`/api/system-settings/organization-name`, { + credentials: 'include', + }); + if (!response.ok) { + throw new Error(`Failed to fetch organization name: ${response.statusText}`); + } + return response.json(); +} + +/** + * Update organization name (superadmin only) + */ +export async function updateOrganizationName(name: string): Promise<{ name: string; message: string }> { + const response = await fetch(`/api/system-settings/organization-name`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ name }), + }); + if (!response.ok) { + throw new Error(`Failed to update organization name: ${response.statusText}`); + } + return response.json(); +} diff --git a/dashboard/src/settings.tsx b/dashboard/src/settings.tsx index 94ceccd..b113a63 100644 --- a/dashboard/src/settings.tsx +++ b/dashboard/src/settings.tsx @@ -88,6 +88,14 @@ const Einstellungen: React.FC = () => { const [videoMuted, setVideoMuted] = React.useState(false); const [videoBusy, setVideoBusy] = React.useState(false); + // Organization name state (Superadmin only) + const [organizationName, setOrganizationName] = React.useState(''); + const [organizationBusy, setOrganizationBusy] = React.useState(false); + + // Advanced options: Scheduler refresh interval (Superadmin) + const [refreshSeconds, setRefreshSeconds] = React.useState(0); + const [refreshBusy, setRefreshBusy] = React.useState(false); + const showToast = (content: string, cssClass: string = 'e-toast-success') => { if (toastRef.current) { toastRef.current.show({ @@ -153,6 +161,30 @@ const Einstellungen: React.FC = () => { } }, []); + // Load organization name (superadmin only, but endpoint is public) + const loadOrganizationName = React.useCallback(async () => { + try { + const api = await import('./apiSystemSettings'); + const data = await api.getOrganizationName(); + setOrganizationName(data.name || ''); + } catch (e) { + console.error('Fehler beim Laden des Organisationsnamens:', e); + } + }, []); + + // Load scheduler refresh seconds (superadmin) + const loadRefreshSeconds = React.useCallback(async () => { + try { + const api = await import('./apiSystemSettings'); + const setting = await api.getSetting('refresh_seconds'); + const parsed = Number(setting.value ?? 0); + setRefreshSeconds(Number.isFinite(parsed) ? parsed : 0); + } catch { + // Default to 0 if not present + setRefreshSeconds(0); + } + }, []); + const loadAcademicPeriods = React.useCallback(async () => { try { const [list, active] = await Promise.all([ @@ -179,8 +211,13 @@ const Einstellungen: React.FC = () => { loadPresentationSettings(); loadVideoSettings(); } + // Organization name only for superadmin + if (user.role === 'superadmin') { + loadOrganizationName(); + loadRefreshSeconds(); + } } - }, [refresh, loadSupplementSettings, loadAcademicPeriods, loadPresentationSettings, loadVideoSettings, loadHolidayBannerSetting, user]); + }, [refresh, loadSupplementSettings, loadAcademicPeriods, loadPresentationSettings, loadVideoSettings, loadHolidayBannerSetting, loadOrganizationName, loadRefreshSeconds, user]); const onUpload = async () => { if (!file) return; @@ -254,6 +291,36 @@ const Einstellungen: React.FC = () => { } }; + const onSaveOrganizationName = async () => { + setOrganizationBusy(true); + try { + const api = await import('./apiSystemSettings'); + await api.updateOrganizationName(organizationName); + showToast('Organisationsname gespeichert', 'e-toast-success'); + // Trigger a custom event to notify App.tsx to refresh the header + window.dispatchEvent(new Event('organizationNameUpdated')); + } catch (e) { + const msg = e instanceof Error ? e.message : 'Fehler beim Speichern des Organisationsnamens'; + showToast(msg, 'e-toast-danger'); + } finally { + setOrganizationBusy(false); + } + }; + + const onSaveRefreshSeconds = async () => { + setRefreshBusy(true); + try { + const api = await import('./apiSystemSettings'); + await api.updateSetting('refresh_seconds', String(refreshSeconds)); + showToast('Scheduler-Refresh-Intervall gespeichert', 'e-toast-success'); + } catch (e) { + const msg = e instanceof Error ? e.message : 'Fehler beim Speichern des Intervalls'; + showToast(msg, 'e-toast-danger'); + } finally { + setRefreshBusy(false); + } + }; + // Determine which tabs to show based on role const isAdmin = !!(user && ['admin', 'superadmin'].includes(user.role)); const isSuperadmin = !!(user && user.role === 'superadmin'); @@ -700,8 +767,30 @@ const Einstellungen: React.FC = () => {
Organisationsinformationen
-
- Platzhalter für Organisationsname, Branding, Standard-Lokalisierung. +
+
+ + setOrganizationName(e.value || '')} + cssClass="e-outline" + floatLabelType="Never" + style={{ width: '100%', maxWidth: '500px' }} + /> +
+ Dieser Name wird im Header des Dashboards angezeigt. +
+
+ + {organizationBusy ? 'Speichern...' : 'Speichern'} +
@@ -715,8 +804,35 @@ const Einstellungen: React.FC = () => {
Erweiterte Konfiguration
-
- Platzhalter für System-weit fortgeschrittene Optionen. +
+
+ + setRefreshSeconds(Number(e.value ?? 0))} + cssClass="e-outline" + width={200} + /> +
+ Legt fest, wie oft der Scheduler Ereignisse an Clients republiziert, auch wenn sich nichts geändert hat. +
+ 0: Deaktiviert (Scheduler sendet nur bei Änderungen). +
+ >0: Sekunden zwischen Republizierungen (z.B. 600 = alle 10 Min). +
+ Der Scheduler liest die Datenbank immer alle 30 Sekunden neu ab und sendet dann bei Bedarf die Änderungen an den Terminen. +
+
+ + {refreshBusy ? 'Speichern...' : 'Speichern'} + +
+
diff --git a/scheduler/db_utils.py b/scheduler/db_utils.py index 8a58b04..17ed8f9 100644 --- a/scheduler/db_utils.py +++ b/scheduler/db_utils.py @@ -5,7 +5,7 @@ from datetime import datetime import logging from sqlalchemy.orm import sessionmaker, joinedload from sqlalchemy import create_engine, or_, and_, text -from models.models import Event, EventMedia, EventException +from models.models import Event, EventMedia, EventException, SystemSetting from dateutil.rrule import rrulestr from urllib.request import Request, urlopen from datetime import timezone @@ -168,6 +168,22 @@ def get_active_events(start: datetime, end: datetime, group_id: int = None): session.close() +def get_system_setting_value(key: str, default: str | None = None) -> str | None: + """Fetch a system setting value by key from DB. + + Returns the setting's string value or the provided default if missing. + """ + session = Session() + try: + setting = session.query(SystemSetting).filter_by(key=key).first() + return setting.value if setting else default + except Exception as e: + logging.debug(f"[Scheduler] Failed to read system setting '{key}': {e}") + return default + finally: + session.close() + + def format_event_with_media(event): """Transform Event + EventMedia into client-expected format""" event_dict = { diff --git a/scheduler/scheduler.py b/scheduler/scheduler.py index 9575b7e..8fa4b3b 100644 --- a/scheduler/scheduler.py +++ b/scheduler/scheduler.py @@ -2,7 +2,7 @@ import os import logging -from .db_utils import get_active_events +from .db_utils import get_active_events, get_system_setting_value import paho.mqtt.client as mqtt import json import datetime @@ -37,7 +37,12 @@ def main(): POLL_INTERVAL = 30 # Sekunden, Empfehlung für seltene Änderungen # 0 = aus; z.B. 600 für alle 10 Min - REFRESH_SECONDS = int(os.getenv("REFRESH_SECONDS", "0")) + # initial value from DB or fallback to env + try: + db_val = get_system_setting_value("refresh_seconds", None) + REFRESH_SECONDS = int(db_val) if db_val is not None else int(os.getenv("REFRESH_SECONDS", "0")) + except Exception: + REFRESH_SECONDS = int(os.getenv("REFRESH_SECONDS", "0")) # Konfigurierbares Zeitfenster in Tagen (Standard: 7) WINDOW_DAYS = int(os.getenv("EVENTS_WINDOW_DAYS", "7")) last_payloads = {} # group_id -> payload @@ -58,6 +63,12 @@ def main(): while True: now = datetime.datetime.now(datetime.timezone.utc) + # refresh interval can change at runtime (superadmin settings) + try: + db_val = get_system_setting_value("refresh_seconds", None) + REFRESH_SECONDS = int(db_val) if db_val is not None else REFRESH_SECONDS + except Exception: + pass # Query window: next N days to capture upcoming events and recurring instances # Clients need to know what's coming, not just what's active right now end_window = now + datetime.timedelta(days=WINDOW_DAYS) diff --git a/server/init_defaults.py b/server/init_defaults.py index ea5e92a..0dad2b6 100644 --- a/server/init_defaults.py +++ b/server/init_defaults.py @@ -66,6 +66,8 @@ with engine.connect() as conn: ('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'), + ('organization_name', '', 'Name der Organisation (wird im Header angezeigt)'), + ('refresh_seconds', '0', 'Scheduler Republish-Intervall (Sekunden; 0 deaktiviert)'), ] for key, value, description in default_settings: diff --git a/server/routes/system_settings.py b/server/routes/system_settings.py index 64db2e4..a36279c 100644 --- a/server/routes/system_settings.py +++ b/server/routes/system_settings.py @@ -5,7 +5,7 @@ Provides key-value storage for system-wide configuration. from flask import Blueprint, jsonify, request from server.database import Session from models.models import SystemSetting -from server.permissions import admin_or_higher +from server.permissions import admin_or_higher, superadmin_only from sqlalchemy.exc import SQLAlchemyError system_settings_bp = Blueprint('system_settings', __name__, url_prefix='/api/system-settings') @@ -31,11 +31,10 @@ def get_all_settings(): @system_settings_bp.route('/', methods=['GET']) -@admin_or_higher def get_setting(key): """ Get a specific system setting by key. - Admin+ only. + Public endpoint - settings are read-only configuration. """ session = Session() try: @@ -265,3 +264,66 @@ def update_holiday_banner_setting(): finally: session.close() + +@system_settings_bp.route('/organization-name', methods=['GET']) +def get_organization_name(): + """ + Get organization name. + Public endpoint - header needs this. + """ + session = Session() + try: + setting = session.query(SystemSetting).filter_by(key='organization_name').first() + name = setting.value if setting and setting.value else '' + + return jsonify({'name': name}), 200 + except SQLAlchemyError as e: + return jsonify({'error': str(e)}), 500 + finally: + session.close() + + +@system_settings_bp.route('/organization-name', methods=['POST']) +@superadmin_only +def update_organization_name(): + """ + Update organization name. + Superadmin only. + + Request body: + { + "name": "Meine Organisation" + } + """ + session = Session() + try: + data = request.get_json() + if not data: + return jsonify({'error': 'No data provided'}), 400 + + name = data.get('name', '') + + # Update or create setting + setting = session.query(SystemSetting).filter_by(key='organization_name').first() + if setting: + setting.value = name + else: + setting = SystemSetting( + key='organization_name', + value=name, + description='Name der Organisation (wird im Header angezeigt)' + ) + session.add(setting) + + session.commit() + + return jsonify({ + 'name': name, + 'message': 'Organization name updated successfully' + }), 200 + except SQLAlchemyError as e: + session.rollback() + return jsonify({'error': str(e)}), 500 + finally: + session.close() +