From 10f446dfb56ccaadac736c18997228df0cead3c9 Mon Sep 17 00:00:00 2001
From: RobbStarkAustria <7694336+RobbStarkAustria@users.noreply.github.com>
Date: Sat, 10 Jan 2026 08:33:18 +0000
Subject: [PATCH] feat: Add organization name and scheduler refresh interval
settings
- Superadmin-only organization name setting displayed in dashboard header
- Advanced Options tab with configurable scheduler refresh interval (0 = disabled)
- Make system settings GET endpoint public for frontend reads
- Scheduler reads refresh_seconds from DB dynamically each loop
- Seed default system settings in init_defaults.py
---
.gitignore | 1 +
dashboard/public/program-info.json | 4 +-
dashboard/src/App.tsx | 28 ++++++-
dashboard/src/apiSystemSettings.ts | 29 +++++++
dashboard/src/settings.tsx | 126 +++++++++++++++++++++++++++--
scheduler/db_utils.py | 18 ++++-
scheduler/scheduler.py | 15 +++-
server/init_defaults.py | 2 +
server/routes/system_settings.py | 68 +++++++++++++++-
9 files changed, 275 insertions(+), 16 deletions(-)
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.
+
+
+
+ Organisationsname
+
+
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.
+
+
+
+ Scheduler: Refresh-Intervall (Sekunden)
+
+
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()
+