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
This commit is contained in:
RobbStarkAustria
2026-01-10 08:33:18 +00:00
parent 5a0c1bc686
commit 10f446dfb5
9 changed files with 275 additions and 16 deletions

1
.gitignore vendored
View File

@@ -74,3 +74,4 @@ dashboard/pages/test.py
dashboard/sidebar_test.py dashboard/sidebar_test.py
dashboard/assets/responsive-sidebar.css dashboard/assets/responsive-sidebar.css
dashboard/src/nested_tabs.js dashboard/src/nested_tabs.js
scheduler/scheduler.log.2

View File

@@ -1,7 +1,7 @@
{ {
"appName": "Infoscreen-Management", "appName": "Infoscreen-Management",
"version": "2025.1.0-alpha.13", "version": "2026.1.0-alpha.13",
"copyright": "© 2025 Third-Age-Applications", "copyright": "© 2026 Third-Age-Applications",
"supportContact": "support@third-age-applications.com", "supportContact": "support@third-age-applications.com",
"description": "Eine zentrale Verwaltungsoberfläche für digitale Informationsbildschirme.", "description": "Eine zentrale Verwaltungsoberfläche für digitale Informationsbildschirme.",
"techStack": { "techStack": {

View File

@@ -61,6 +61,7 @@ import { useToast } from './components/ToastProvider';
const Layout: React.FC = () => { const Layout: React.FC = () => {
const [version, setVersion] = useState(''); const [version, setVersion] = useState('');
const [isCollapsed, setIsCollapsed] = useState(false); const [isCollapsed, setIsCollapsed] = useState(false);
const [organizationName, setOrganizationName] = useState('');
let sidebarRef: SidebarComponent | null; let sidebarRef: SidebarComponent | null;
const { user } = useAuth(); const { user } = useAuth();
const toast = useToast(); const toast = useToast();
@@ -80,6 +81,25 @@ const Layout: React.FC = () => {
.catch(err => console.error('Failed to load version info:', err)); .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 = () => { const toggleSidebar = () => {
if (sidebarRef) { if (sidebarRef) {
sidebarRef.toggle(); sidebarRef.toggle();
@@ -346,9 +366,11 @@ const Layout: React.FC = () => {
Infoscreen-Management Infoscreen-Management
</span> </span>
<div style={{ marginLeft: 'auto', display: 'inline-flex', alignItems: 'center', gap: 16 }}> <div style={{ marginLeft: 'auto', display: 'inline-flex', alignItems: 'center', gap: 16 }}>
{organizationName && (
<span className="text-lg font-medium" style={{ color: '#78591c' }}> <span className="text-lg font-medium" style={{ color: '#78591c' }}>
[Organisationsname] {organizationName}
</span> </span>
)}
{user && ( {user && (
<DropDownButtonComponent <DropDownButtonComponent
items={[ items={[

View File

@@ -137,3 +137,32 @@ export async function updateHolidayBannerSetting(
} }
return response.json(); return response.json();
} }
/**
* Get organization name (public endpoint)
*/
export async function getOrganizationName(): Promise<{ name: string }> {
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();
}

View File

@@ -88,6 +88,14 @@ const Einstellungen: React.FC = () => {
const [videoMuted, setVideoMuted] = React.useState<boolean>(false); const [videoMuted, setVideoMuted] = React.useState<boolean>(false);
const [videoBusy, setVideoBusy] = React.useState<boolean>(false); const [videoBusy, setVideoBusy] = React.useState<boolean>(false);
// Organization name state (Superadmin only)
const [organizationName, setOrganizationName] = React.useState<string>('');
const [organizationBusy, setOrganizationBusy] = React.useState<boolean>(false);
// Advanced options: Scheduler refresh interval (Superadmin)
const [refreshSeconds, setRefreshSeconds] = React.useState<number>(0);
const [refreshBusy, setRefreshBusy] = React.useState<boolean>(false);
const showToast = (content: string, cssClass: string = 'e-toast-success') => { const showToast = (content: string, cssClass: string = 'e-toast-success') => {
if (toastRef.current) { if (toastRef.current) {
toastRef.current.show({ 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 () => { const loadAcademicPeriods = React.useCallback(async () => {
try { try {
const [list, active] = await Promise.all([ const [list, active] = await Promise.all([
@@ -179,8 +211,13 @@ const Einstellungen: React.FC = () => {
loadPresentationSettings(); loadPresentationSettings();
loadVideoSettings(); 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 () => { const onUpload = async () => {
if (!file) return; 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 // Determine which tabs to show based on role
const isAdmin = !!(user && ['admin', 'superadmin'].includes(user.role)); const isAdmin = !!(user && ['admin', 'superadmin'].includes(user.role));
const isSuperadmin = !!(user && user.role === 'superadmin'); const isSuperadmin = !!(user && user.role === 'superadmin');
@@ -700,8 +767,30 @@ const Einstellungen: React.FC = () => {
<div className="e-card-header-title">Organisationsinformationen</div> <div className="e-card-header-title">Organisationsinformationen</div>
</div> </div>
</div> </div>
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}> <div className="e-card-content" style={{ padding: '16px' }}>
Platzhalter für Organisationsname, Branding, Standard-Lokalisierung. <div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', marginBottom: 8, fontWeight: 600 }}>
Organisationsname
</label>
<TextBoxComponent
placeholder="z.B. Meine Schule GmbH"
value={organizationName}
change={(e) => setOrganizationName(e.value || '')}
cssClass="e-outline"
floatLabelType="Never"
style={{ width: '100%', maxWidth: '500px' }}
/>
<div style={{ marginTop: 8, color: '#666', fontSize: 13 }}>
Dieser Name wird im Header des Dashboards angezeigt.
</div>
</div>
<ButtonComponent
cssClass="e-primary"
onClick={onSaveOrganizationName}
disabled={organizationBusy}
>
{organizationBusy ? 'Speichern...' : 'Speichern'}
</ButtonComponent>
</div> </div>
</div> </div>
</div> </div>
@@ -715,8 +804,35 @@ const Einstellungen: React.FC = () => {
<div className="e-card-header-title">Erweiterte Konfiguration</div> <div className="e-card-header-title">Erweiterte Konfiguration</div>
</div> </div>
</div> </div>
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}> <div className="e-card-content" style={{ padding: '16px' }}>
Platzhalter für System-weit fortgeschrittene Optionen. <div style={{ marginBottom: 20 }}>
<label style={{ display: 'block', marginBottom: 8, fontWeight: 600 }}>
Scheduler: Refresh-Intervall (Sekunden)
</label>
<NumericTextBoxComponent
value={refreshSeconds}
min={0}
step={1}
format="n0"
change={(e) => setRefreshSeconds(Number(e.value ?? 0))}
cssClass="e-outline"
width={200}
/>
<div style={{ marginTop: 8, color: '#666', fontSize: 13 }}>
Legt fest, wie oft der Scheduler Ereignisse an Clients republiziert, auch wenn sich nichts geändert hat.
<br />
<strong>0:</strong> Deaktiviert (Scheduler sendet nur bei Änderungen).
<br />
<strong>&gt;0:</strong> Sekunden zwischen Republizierungen (z.B. 600 = alle 10 Min).
<br />
Der Scheduler liest die Datenbank immer alle 30 Sekunden neu ab und sendet dann bei Bedarf die Änderungen an den Terminen.
</div>
<div style={{ marginTop: 12 }}>
<ButtonComponent cssClass="e-primary" onClick={onSaveRefreshSeconds} disabled={refreshBusy}>
{refreshBusy ? 'Speichern...' : 'Speichern'}
</ButtonComponent>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -5,7 +5,7 @@ from datetime import datetime
import logging import logging
from sqlalchemy.orm import sessionmaker, joinedload from sqlalchemy.orm import sessionmaker, joinedload
from sqlalchemy import create_engine, or_, and_, text 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 dateutil.rrule import rrulestr
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
from datetime import timezone from datetime import timezone
@@ -168,6 +168,22 @@ def get_active_events(start: datetime, end: datetime, group_id: int = None):
session.close() 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): def format_event_with_media(event):
"""Transform Event + EventMedia into client-expected format""" """Transform Event + EventMedia into client-expected format"""
event_dict = { event_dict = {

View File

@@ -2,7 +2,7 @@
import os import os
import logging 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 paho.mqtt.client as mqtt
import json import json
import datetime import datetime
@@ -37,6 +37,11 @@ def main():
POLL_INTERVAL = 30 # Sekunden, Empfehlung für seltene Änderungen POLL_INTERVAL = 30 # Sekunden, Empfehlung für seltene Änderungen
# 0 = aus; z.B. 600 für alle 10 Min # 0 = aus; z.B. 600 für alle 10 Min
# 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")) REFRESH_SECONDS = int(os.getenv("REFRESH_SECONDS", "0"))
# Konfigurierbares Zeitfenster in Tagen (Standard: 7) # Konfigurierbares Zeitfenster in Tagen (Standard: 7)
WINDOW_DAYS = int(os.getenv("EVENTS_WINDOW_DAYS", "7")) WINDOW_DAYS = int(os.getenv("EVENTS_WINDOW_DAYS", "7"))
@@ -58,6 +63,12 @@ def main():
while True: while True:
now = datetime.datetime.now(datetime.timezone.utc) 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 # 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 # Clients need to know what's coming, not just what's active right now
end_window = now + datetime.timedelta(days=WINDOW_DAYS) end_window = now + datetime.timedelta(days=WINDOW_DAYS)

View File

@@ -66,6 +66,8 @@ with engine.connect() as conn:
('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'), ('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: for key, value, description in default_settings:

View File

@@ -5,7 +5,7 @@ Provides key-value storage for system-wide configuration.
from flask import Blueprint, jsonify, request from flask import Blueprint, jsonify, request
from server.database import Session from server.database import Session
from models.models import SystemSetting 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 from sqlalchemy.exc import SQLAlchemyError
system_settings_bp = Blueprint('system_settings', __name__, url_prefix='/api/system-settings') system_settings_bp = Blueprint('system_settings', __name__, url_prefix='/api/system-settings')
@@ -31,11 +31,10 @@ def get_all_settings():
@system_settings_bp.route('/<key>', methods=['GET']) @system_settings_bp.route('/<key>', methods=['GET'])
@admin_or_higher
def get_setting(key): def get_setting(key):
""" """
Get a specific system setting by key. Get a specific system setting by key.
Admin+ only. Public endpoint - settings are read-only configuration.
""" """
session = Session() session = Session()
try: try:
@@ -265,3 +264,66 @@ def update_holiday_banner_setting():
finally: finally:
session.close() 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()