docs(settings): Update README + Copilot instructions; bump Program Info to 2025.1.0-alpha.11

README: Add System Settings API endpoints; describe new tabbed Settings layout with role gating; add Vite dev proxy tip to use relative /api paths.
Copilot instructions: Note SystemSetting key–value store in data model; document system_settings.py (CRUD + supplement-table convenience endpoint); reference apiSystemSettings.ts; note defaults seeding via init_defaults.py.
Program Info: Bump version to 2025.1.0-alpha.11; changelog explicitly tied to the Settings page (Events tab: supplement-table URL moved; Academic Calendar: set active period; proxy note); README docs mention.
No functional changes to API or UI code in this commit; documentation and program info only.
This commit is contained in:
RobbStarkAustria
2025-10-16 19:15:55 +00:00
parent 7b38b49598
commit 150937f2e2
11 changed files with 882 additions and 50 deletions

View File

@@ -38,6 +38,7 @@ Use this as your shared context when proposing changes. Keep edits minimal and m
## Data model highlights (see `models/models.py`) ## Data model highlights (see `models/models.py`)
- Enums: `EventType` (presentation, website, video, message, webuntis), `MediaType` (file/website types), and `AcademicPeriodType` (schuljahr, semester, trimester). - Enums: `EventType` (presentation, website, video, message, webuntis), `MediaType` (file/website types), and `AcademicPeriodType` (schuljahr, semester, trimester).
- Tables: `clients`, `client_groups`, `events`, `event_media`, `users`, `academic_periods`, `school_holidays`. - Tables: `clients`, `client_groups`, `events`, `event_media`, `users`, `academic_periods`, `school_holidays`.
- System settings: `system_settings` keyvalue store via `SystemSetting` for global configuration (e.g., WebUntis/Vertretungsplan supplement-table). Managed through routes in `server/routes/system_settings.py`.
- Academic periods: `academic_periods` table supports educational institution cycles (school years, semesters). Events and media can be optionally linked via `academic_period_id` (nullable for backward compatibility). - Academic periods: `academic_periods` table supports educational institution cycles (school years, semesters). Events and media can be optionally linked via `academic_period_id` (nullable for backward compatibility).
- Times are stored as timezone-aware; treat comparisons in UTC (see scheduler and routes/events). - Times are stored as timezone-aware; treat comparisons in UTC (see scheduler and routes/events).
@@ -54,6 +55,7 @@ Use this as your shared context when proposing changes. Keep edits minimal and m
- Groups: `server/routes/groups.py` computes “alive” using a grace period that varies by `ENV`. - Groups: `server/routes/groups.py` computes “alive” using a grace period that varies by `ENV`.
- Events: `server/routes/events.py` serializes enum values to strings and normalizes times to UTC. - Events: `server/routes/events.py` serializes enum values to strings and normalizes times to UTC.
- Media: `server/routes/eventmedia.py` implements a simple file manager API rooted at `server/media/`. - Media: `server/routes/eventmedia.py` implements a simple file manager API rooted at `server/media/`.
- System settings: `server/routes/system_settings.py` exposes keyvalue CRUD (`/api/system-settings`) and a convenience endpoint for WebUntis/Vertretungsplan supplement-table: `GET/POST /api/system-settings/supplement-table` (admin+).
- Academic periods: `server/routes/academic_periods.py` exposes: - Academic periods: `server/routes/academic_periods.py` exposes:
- `GET /api/academic_periods` — list all periods - `GET /api/academic_periods` — list all periods
- `GET /api/academic_periods/active` — currently active period - `GET /api/academic_periods/active` — currently active period
@@ -94,6 +96,25 @@ Use this as your shared context when proposing changes. Keep edits minimal and m
- Implemented with Syncfusion DropDownButton (`@syncfusion/ej2-react-splitbuttons`). - Implemented with Syncfusion DropDownButton (`@syncfusion/ej2-react-splitbuttons`).
- “Abmelden” navigates to `/logout`; the page invokes backend logout and redirects to `/login`. - “Abmelden” navigates to `/logout`; the page invokes backend logout and redirects to `/login`.
- Settings page (`dashboard/src/settings.tsx`):
- Structure: Syncfusion TabComponent with role-gated tabs
- 📅 Academic Calendar (all users)
- School Holidays: CSV/TXT import and list
- Academic Periods: select and set active period (uses `/api/academic_periods` routes)
- 🖥️ Display & Clients (admin+)
- Default Settings: placeholders for heartbeat, screenshots, defaults
- Client Configuration: quick links to Clients and Groups pages
- 🎬 Media & Files (admin+)
- Upload Settings: placeholders for limits and types
- Conversion Status: placeholder for conversions overview
- 🗓️ Events (admin+)
- WebUntis / Vertretungsplan: system-wide supplement table URL with enable/disable, save, and preview; persists via `/api/system-settings/supplement-table`
- Other event types (presentation, website, video, message, other): placeholders for defaults
- ⚙️ System (superadmin)
- Organization Info and Advanced Configuration placeholders
- Role gating: Admin/Superadmin tabs are hidden if the user lacks permission; System is superadmin-only
- API clients use relative `/api/...` URLs so Vite dev proxy handles requests without CORS issues. The settings UI calls are centralized in `dashboard/src/apiSystemSettings.ts`.
- User dropdown technical notes: - User dropdown technical notes:
- Dependencies: `@syncfusion/ej2-react-splitbuttons` and `@syncfusion/ej2-splitbuttons` must be installed. - Dependencies: `@syncfusion/ej2-react-splitbuttons` and `@syncfusion/ej2-splitbuttons` must be installed.
- Vite: add both to `optimizeDeps.include` in `vite.config.ts` to avoid import-analysis errors. - Vite: add both to `optimizeDeps.include` in `vite.config.ts` to avoid import-analysis errors.
@@ -109,6 +130,7 @@ Note: Syncfusion usage in the dashboard is already documented above; if a UI for
- Common env vars: `DB_CONN`, `DB_USER`, `DB_PASSWORD`, `DB_HOST=db`, `DB_NAME`, `ENV`, `MQTT_USER`, `MQTT_PASSWORD`. - Common env vars: `DB_CONN`, `DB_USER`, `DB_PASSWORD`, `DB_HOST=db`, `DB_NAME`, `ENV`, `MQTT_USER`, `MQTT_PASSWORD`.
- Alembic: prod compose runs `alembic ... upgrade head` and `server/init_defaults.py` before gunicorn. - Alembic: prod compose runs `alembic ... upgrade head` and `server/init_defaults.py` before gunicorn.
- Local dev: prefer `python server/initialize_database.py` for one-shot setup (migrations + defaults + academic periods). - Local dev: prefer `python server/initialize_database.py` for one-shot setup (migrations + defaults + academic periods).
- Defaults: `server/init_defaults.py` seeds initial system settings like `supplement_table_url` and `supplement_table_enabled` if missing.
- `server/init_academic_periods.py` remains available to (re)seed school years. - `server/init_academic_periods.py` remains available to (re)seed school years.
## Production ## Production

View File

@@ -153,6 +153,7 @@ For detailed deployment instructions, see:
- **Features**: Responsive design, real-time updates, file management - **Features**: Responsive design, real-time updates, file management
- **Port**: 5173 (dev), served via Nginx (prod) - **Port**: 5173 (dev), served via Nginx (prod)
- **Data access**: No direct database connection; communicates with the API Server only via HTTP. - **Data access**: No direct database connection; communicates with the API Server only via HTTP.
- **Dev proxy tip**: In development, use relative paths like `/api/...` in the frontend to route through Vite's proxy to the API. Avoid absolute URLs with an extra `/api` segment to prevent CORS or double-path issues.
### 🔧 **API Server** (`server/`) ### 🔧 **API Server** (`server/`)
- **Technology**: Flask + SQLAlchemy + Alembic - **Technology**: Flask + SQLAlchemy + Alembic
@@ -285,6 +286,14 @@ mosquitto_sub -h localhost -t "infoscreen/+/heartbeat" -v
- `POST /api/conversions/{media_id}/pdf` - Request conversion - `POST /api/conversions/{media_id}/pdf` - Request conversion
- `GET /api/conversions/{media_id}/status` - Check conversion status - `GET /api/conversions/{media_id}/status` - Check conversion status
### System Settings
- `GET /api/system-settings` - List all system settings (admin+)
- `GET /api/system-settings/{key}` - Get a specific setting (admin+)
- `POST /api/system-settings/{key}` - Create or update a setting (admin+)
- `DELETE /api/system-settings/{key}` - Delete a setting (admin+)
- `GET /api/system-settings/supplement-table` - Get WebUntis/Vertretungsplan settings (enabled, url)
- `POST /api/system-settings/supplement-table` - Update WebUntis/Vertretungsplan settings
### Health & Monitoring ### Health & Monitoring
- `GET /health` - Service health check - `GET /health` - Service health check
- `GET /api/screenshots/{uuid}.jpg` - Client screenshots - `GET /api/screenshots/{uuid}.jpg` - Client screenshots
@@ -315,7 +324,12 @@ mosquitto_sub -h localhost -t "infoscreen/+/heartbeat" -v
- **Groups**: Client group organization - **Groups**: Client group organization
- **Events**: Schedule management - **Events**: Schedule management
- **Media**: File upload and conversion - **Media**: File upload and conversion
- **Settings**: System configuration - **Settings**: Central configuration (tabbed)
- 📅 Academic Calendar (all users): School Holidays import/list and Academic Periods (set active period)
- 🖥️ Display & Clients (admin+): Defaults placeholders and quick links to Clients/Groups
- 🎬 Media & Files (admin+): Upload settings placeholders and Conversion status overview
- 🗓️ Events (admin+): WebUntis/Vertretungsplan URL enable/disable, save, preview; placeholders for other event types
- ⚙️ System (superadmin): Organization info and Advanced configuration placeholders
- **Holidays**: Academic calendar management - **Holidays**: Academic calendar management
- **Program info**: Version, build info, tech stack and paginated changelog (reads `dashboard/public/program-info.json`) - **Program info**: Version, build info, tech stack and paginated changelog (reads `dashboard/public/program-info.json`)

View File

@@ -1,6 +1,6 @@
{ {
"appName": "Infoscreen-Management", "appName": "Infoscreen-Management",
"version": "2025.1.0-alpha.10", "version": "2025.1.0-alpha.11",
"copyright": "© 2025 Third-Age-Applications", "copyright": "© 2025 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.",
@@ -30,6 +30,17 @@
"commitId": "8d1df7199cb7" "commitId": "8d1df7199cb7"
}, },
"changelog": [ "changelog": [
{
"version": "2025.1.0-alpha.11",
"date": "2025-10-16",
"changes": [
"✨ Einstellungen-Seite: Neues Tab-Layout (Syncfusion) mit rollenbasierter Sichtbarkeit Tabs: 📅 Akademischer Kalender, 🖥️ Anzeige & Clients, 🎬 Medien & Dateien, 🗓️ Events, ⚙️ System.",
"🗓️ Einstellungen Events: WebUntis/Vertretungsplan Zusatz-Tabelle (URL) in den Events-Tab verschoben; Aktivieren/Deaktivieren, Speichern und Vorschau; systemweite Einstellung.",
"📅 Einstellungen Akademischer Kalender: Aktive akademische Periode kann direkt gesetzt werden.",
"🛠️ Einstellungen (Technik): API-Aufrufe nutzen nun relative /apiPfade über den ViteProxy (verhindert CORS bzw. doppeltes /api).",
"📖 Doku: README zur Einstellungen-Seite (Tabs) und System-Settings-API ergänzt."
]
},
{ {
"version": "2025.1.0-alpha.10", "version": "2025.1.0-alpha.10",
"date": "2025-10-15", "date": "2025-10-15",

View File

@@ -0,0 +1,108 @@
/**
* API client for system settings
*/
export interface SystemSetting {
key: string;
value: string | null;
description: string | null;
updated_at: string | null;
}
export interface SupplementTableSettings {
url: string;
enabled: boolean;
}
/**
* Get all system settings
*/
export async function getAllSettings(): Promise<{ settings: SystemSetting[] }> {
const response = await fetch(`/api/system-settings`, {
credentials: 'include',
});
if (!response.ok) {
throw new Error(`Failed to fetch settings: ${response.statusText}`);
}
return response.json();
}
/**
* Get a specific setting by key
*/
export async function getSetting(key: string): Promise<SystemSetting> {
const response = await fetch(`/api/system-settings/${key}`, {
credentials: 'include',
});
if (!response.ok) {
throw new Error(`Failed to fetch setting: ${response.statusText}`);
}
return response.json();
}
/**
* Update or create a setting
*/
export async function updateSetting(
key: string,
value: string,
description?: string
): Promise<SystemSetting> {
const response = await fetch(`/api/system-settings/${key}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ value, description }),
});
if (!response.ok) {
throw new Error(`Failed to update setting: ${response.statusText}`);
}
return response.json();
}
/**
* Delete a setting
*/
export async function deleteSetting(key: string): Promise<{ message: string }> {
const response = await fetch(`/api/system-settings/${key}`, {
method: 'DELETE',
credentials: 'include',
});
if (!response.ok) {
throw new Error(`Failed to delete setting: ${response.statusText}`);
}
return response.json();
}
/**
* Get supplement table settings
*/
export async function getSupplementTableSettings(): Promise<SupplementTableSettings> {
const response = await fetch(`/api/system-settings/supplement-table`, {
credentials: 'include',
});
if (!response.ok) {
throw new Error(`Failed to fetch supplement table settings: ${response.statusText}`);
}
return response.json();
}
/**
* Update supplement table settings
*/
export async function updateSupplementTableSettings(
url: string,
enabled: boolean
): Promise<SupplementTableSettings & { message: string }> {
const response = await fetch(`/api/system-settings/supplement-table`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ url, enabled }),
});
if (!response.ok) {
throw new Error(`Failed to update supplement table settings: ${response.statusText}`);
}
return response.json();
}

View File

@@ -1,11 +1,46 @@
import React from 'react'; import React from 'react';
import { TabComponent, TabItemDirective, TabItemsDirective } from '@syncfusion/ej2-react-navigations';
import { TextBoxComponent } from '@syncfusion/ej2-react-inputs';
import { ButtonComponent } from '@syncfusion/ej2-react-buttons';
import { CheckBoxComponent } from '@syncfusion/ej2-react-buttons';
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 { useAuth } from './useAuth';
import { DropDownListComponent } from '@syncfusion/ej2-react-dropdowns';
import { listAcademicPeriods, getActiveAcademicPeriod, setActiveAcademicPeriod, type AcademicPeriod } from './apiAcademicPeriods';
import { Link } from 'react-router-dom';
const Einstellungen: React.FC = () => { const Einstellungen: React.FC = () => {
const { user } = useAuth();
const toastRef = React.useRef<ToastComponent>(null);
// Holidays state
const [file, setFile] = React.useState<File | null>(null); const [file, setFile] = React.useState<File | null>(null);
const [busy, setBusy] = React.useState(false); const [busy, setBusy] = React.useState(false);
const [message, setMessage] = React.useState<string | null>(null); const [message, setMessage] = React.useState<string | null>(null);
const [holidays, setHolidays] = React.useState<Holiday[]>([]); const [holidays, setHolidays] = React.useState<Holiday[]>([]);
const [periods, setPeriods] = React.useState<AcademicPeriod[]>([]);
const [activePeriodId, setActivePeriodId] = React.useState<number | null>(null);
const periodOptions = React.useMemo(() =>
periods.map(p => ({ id: p.id, name: p.display_name || p.name })),
[periods]
);
// Supplement table state
const [supplementUrl, setSupplementUrl] = React.useState('');
const [supplementEnabled, setSupplementEnabled] = React.useState(false);
const [supplementBusy, setSupplementBusy] = React.useState(false);
const showToast = (content: string, cssClass: string = 'e-toast-success') => {
if (toastRef.current) {
toastRef.current.show({
content,
cssClass,
timeOut: 3000,
});
}
};
const refresh = React.useCallback(async () => { const refresh = React.useCallback(async () => {
try { try {
@@ -14,12 +49,46 @@ const Einstellungen: React.FC = () => {
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Laden der Ferien'; const msg = e instanceof Error ? e.message : 'Fehler beim Laden der Ferien';
setMessage(msg); setMessage(msg);
showToast(msg, 'e-toast-danger');
}
}, []);
const loadSupplementSettings = React.useCallback(async () => {
try {
const data = await getSupplementTableSettings();
setSupplementUrl(data.url || '');
setSupplementEnabled(data.enabled || false);
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Laden der Vertretungsplan-Einstellungen';
showToast(msg, 'e-toast-danger');
}
}, []);
const loadAcademicPeriods = React.useCallback(async () => {
try {
const [list, active] = await Promise.all([
listAcademicPeriods(),
getActiveAcademicPeriod(),
]);
setPeriods(list);
setActivePeriodId(active ? active.id : null);
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Laden der Schuljahre/Perioden';
showToast(msg, 'e-toast-danger');
} }
}, []); }, []);
React.useEffect(() => { React.useEffect(() => {
refresh(); refresh();
}, [refresh]); if (user) {
// Academic periods for all users
loadAcademicPeriods();
// System settings only for admin/superadmin (will render only if allowed)
if (['admin', 'superadmin'].includes(user.role)) {
loadSupplementSettings();
}
}
}, [refresh, loadSupplementSettings, loadAcademicPeriods, user]);
const onUpload = async () => { const onUpload = async () => {
if (!file) return; if (!file) return;
@@ -27,59 +96,375 @@ const Einstellungen: React.FC = () => {
setMessage(null); setMessage(null);
try { try {
const res = await uploadHolidaysCsv(file); const res = await uploadHolidaysCsv(file);
setMessage(`Import erfolgreich: ${res.inserted} neu, ${res.updated} aktualisiert.`); const msg = `Import erfolgreich: ${res.inserted} neu, ${res.updated} aktualisiert.`;
setMessage(msg);
showToast(msg, 'e-toast-success');
await refresh(); await refresh();
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Import.'; const msg = e instanceof Error ? e.message : 'Fehler beim Import.';
setMessage(msg); setMessage(msg);
showToast(msg, 'e-toast-danger');
} finally { } finally {
setBusy(false); setBusy(false);
} }
}; };
return ( const onSaveSupplementSettings = async () => {
<div> setSupplementBusy(true);
<h2 className="text-xl font-bold mb-4">Einstellungen</h2> try {
<div className="space-y-4"> await updateSupplementTableSettings(supplementUrl, supplementEnabled);
<section className="p-4 border rounded-md"> showToast('Vertretungsplan-Einstellungen erfolgreich gespeichert', 'e-toast-success');
<h3 className="font-semibold mb-2">Schulferien importieren</h3> } catch (e) {
<p className="text-sm text-gray-600 mb-2"> const msg = e instanceof Error ? e.message : 'Fehler beim Speichern der Einstellungen';
Unterstützte Formate: showToast(msg, 'e-toast-danger');
<br /> CSV mit Kopfzeile: <code>name</code>, <code>start_date</code>,{' '} } finally {
<code>end_date</code>, optional <code>region</code> setSupplementBusy(false);
<br /> TXT/CSV ohne Kopfzeile mit Spalten: interner Name, <strong>Name</strong>,{' '} }
<strong>Start (YYYYMMDD)</strong>, <strong>Ende (YYYYMMDD)</strong>, optional interne };
Info (ignoriert)
</p>
<div className="flex items-center gap-3">
<input
type="file"
accept=".csv,text/csv,.txt,text/plain"
onChange={e => setFile(e.target.files?.[0] ?? null)}
/>
<button className="e-btn e-primary" onClick={onUpload} disabled={!file || busy}>
{busy ? 'Importiere…' : 'CSV/TXT importieren'}
</button>
</div>
{message && <div className="mt-2 text-sm">{message}</div>}
</section>
<section className="p-4 border rounded-md"> const onTestSupplementUrl = () => {
<h3 className="font-semibold mb-2">Importierte Ferien</h3> if (supplementUrl) {
{holidays.length === 0 ? ( window.open(supplementUrl, '_blank');
<div className="text-sm text-gray-600">Keine Einträge vorhanden.</div> } else {
) : ( showToast('Bitte geben Sie zuerst eine URL ein', 'e-toast-warning');
<ul className="text-sm list-disc pl-6"> }
{holidays.slice(0, 20).map(h => ( };
<li key={h.id}>
{h.name}: {h.start_date} {h.end_date} // Determine which tabs to show based on role
{h.region ? ` (${h.region})` : ''} const isAdmin = !!(user && ['admin', 'superadmin'].includes(user.role));
</li> const isSuperadmin = !!(user && user.role === 'superadmin');
))}
</ul> return (
<div style={{ padding: 20 }}>
<ToastComponent ref={toastRef} position={{ X: 'Right', Y: 'Top' }} />
<h2 style={{ marginBottom: 20, fontSize: '24px', fontWeight: 600 }}>Einstellungen</h2>
<TabComponent heightAdjustMode="Auto">
<TabItemsDirective>
{/* 📅 Academic Calendar */}
<TabItemDirective header={{ text: '📅 Akademischer Kalender' }} content={() => (
<div style={{ padding: 20 }}>
{/* Holidays Import */}
<div className="e-card" style={{ marginBottom: 20 }}>
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Schulferien importieren</div>
</div>
</div>
<div className="e-card-content">
<p style={{ marginBottom: 12, fontSize: '14px', color: '#666' }}>
Unterstützte Formate:
<br /> CSV mit Kopfzeile: <code>name</code>, <code>start_date</code>, <code>end_date</code>, optional <code>region</code>
<br /> TXT/CSV ohne Kopfzeile mit Spalten: interner Name, <strong>Name</strong>, <strong>Start (YYYYMMDD)</strong>, <strong>Ende (YYYYMMDD)</strong>, optional interne Info (ignoriert)
</p>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<input type="file" accept=".csv,text/csv,.txt,text/plain" onChange={e => setFile(e.target.files?.[0] ?? null)} />
<ButtonComponent cssClass="e-primary" onClick={onUpload} disabled={!file || busy}>
{busy ? 'Importiere…' : 'CSV/TXT importieren'}
</ButtonComponent>
</div>
{message && <div style={{ marginTop: 8, fontSize: '14px' }}>{message}</div>}
</div>
</div>
{/* Imported Holidays List */}
<div className="e-card" style={{ marginBottom: 20 }}>
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Importierte Ferien</div>
</div>
</div>
<div className="e-card-content">
{holidays.length === 0 ? (
<div style={{ fontSize: '14px', color: '#666' }}>Keine Einträge vorhanden.</div>
) : (
<ul style={{ fontSize: '14px', listStyle: 'disc', paddingLeft: 24 }}>
{holidays.slice(0, 20).map(h => (
<li key={h.id}>
{h.name}: {h.start_date} {h.end_date}
{h.region ? ` (${h.region})` : ''}
</li>
))}
</ul>
)}
</div>
</div>
{/* Academic Periods */}
<div className="e-card">
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Akademische Perioden</div>
</div>
</div>
<div className="e-card-content">
{periods.length === 0 ? (
<div style={{ fontSize: '14px', color: '#666' }}>Keine Perioden gefunden.</div>
) : (
<div style={{ display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap' }}>
<div style={{ minWidth: 260 }}>
<DropDownListComponent
dataSource={periodOptions}
fields={{ text: 'name', value: 'id' }}
value={activePeriodId ?? undefined}
change={(e) => setActivePeriodId(Number(e.value))}
placeholder="Aktive Periode wählen"
popupHeight="250px"
/>
</div>
<ButtonComponent
cssClass="e-primary"
disabled={activePeriodId == null}
onClick={async () => {
if (activePeriodId == null) return;
try {
const p = await setActiveAcademicPeriod(activePeriodId);
showToast(`Aktive Periode gesetzt: ${p.display_name || p.name}`, 'e-toast-success');
await loadAcademicPeriods();
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Setzen der aktiven Periode';
showToast(msg, 'e-toast-danger');
}
}}
>
Als aktiv setzen
</ButtonComponent>
</div>
)}
</div>
</div>
</div>
)} />
{/* 🖥️ Display & Clients (Admin+) */}
{isAdmin && (
<TabItemDirective header={{ text: '🖥️ Anzeige & Clients' }} content={() => (
<div style={{ padding: 20 }}>
<div className="e-card" style={{ marginBottom: 20 }}>
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Standard-Einstellungen</div>
</div>
</div>
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
Platzhalter für Anzeige-Defaults (z. B. Heartbeat-Timeout, Screenshot-Aufbewahrung, Standard-Gruppe).
</div>
</div>
<div className="e-card">
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Client-Konfiguration</div>
</div>
</div>
<div className="e-card-content">
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<Link to="/clients"><ButtonComponent>Infoscreen-Clients öffnen</ButtonComponent></Link>
<Link to="/infoscr_groups"><ButtonComponent>Raumgruppen öffnen</ButtonComponent></Link>
</div>
</div>
</div>
</div>
)} />
)} )}
</section>
</div> {/* 🎬 Media & Files (Admin+) */}
{isAdmin && (
<TabItemDirective header={{ text: '🎬 Medien & Dateien' }} content={() => (
<div style={{ padding: 20 }}>
<div className="e-card" style={{ marginBottom: 20 }}>
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Upload-Einstellungen</div>
</div>
</div>
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
Platzhalter für Upload-Limits, erlaubte Dateitypen und Standardspeicherorte.
</div>
</div>
<div className="e-card">
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Konvertierungsstatus</div>
</div>
</div>
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
Platzhalter Konversionsstatus-Übersicht kann hier integriert werden (siehe API /api/conversions/...).
</div>
</div>
</div>
)} />
)}
{/* <20> Events (Admin+): per-event-type defaults and WebUntis link settings */}
{isAdmin && (
<TabItemDirective header={{ text: '<27> Events' }} content={() => (
<div style={{ padding: 20 }}>
{/* WebUntis / Supplement table URL */}
<div className="e-card" style={{ marginBottom: 20 }}>
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">WebUntis / Vertretungsplan</div>
</div>
</div>
<div className="e-card-content">
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', marginBottom: 8, fontWeight: 500 }}>
Vertretungsplan URL
</label>
<TextBoxComponent
placeholder="https://example.com/vertretungsplan"
value={supplementUrl}
change={(e) => setSupplementUrl(e.value || '')}
cssClass="e-outline"
width="100%"
/>
<div style={{ fontSize: '12px', color: '#666', marginTop: 4 }}>
Diese URL wird verwendet, um die Stundenplan-Änderungstabelle (Vertretungsplan) anzuzeigen.
</div>
</div>
<div style={{ marginBottom: 16 }}>
<CheckBoxComponent
label="Vertretungsplan aktiviert"
checked={supplementEnabled}
change={(e) => setSupplementEnabled(e.checked || false)}
/>
</div>
<div style={{ display: 'flex', gap: 12 }}>
<ButtonComponent
cssClass="e-primary"
onClick={onSaveSupplementSettings}
disabled={supplementBusy}
>
{supplementBusy ? 'Speichere…' : 'Einstellungen speichern'}
</ButtonComponent>
<ButtonComponent
cssClass="e-outline"
onClick={onTestSupplementUrl}
disabled={!supplementUrl}
>
Vorschau öffnen
</ButtonComponent>
</div>
</div>
</div>
{/* Presentation defaults */}
<div className="e-card" style={{ marginBottom: 20 }}>
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Präsentationen</div>
</div>
</div>
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
Platzhalter für Standardwerte (Autoplay, Loop, Intervall) für Präsentationen.
</div>
</div>
{/* Website defaults */}
<div className="e-card" style={{ marginBottom: 20 }}>
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Webseiten</div>
</div>
</div>
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
Platzhalter für Standardwerte (Lade-Timeout, Intervall, Sandbox/Embedding) für Webseiten.
</div>
</div>
{/* Video defaults */}
<div className="e-card" style={{ marginBottom: 20 }}>
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Videos</div>
</div>
</div>
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
Platzhalter für Standardwerte (Autoplay, Loop, Lautstärke) für Videos.
</div>
</div>
{/* Message defaults */}
<div className="e-card" style={{ marginBottom: 20 }}>
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Mitteilungen</div>
</div>
</div>
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
Platzhalter für Standardwerte (Dauer, Layout/Styling) für Mitteilungen.
</div>
</div>
{/* Other defaults */}
<div className="e-card">
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Sonstige</div>
</div>
</div>
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
Platzhalter für sonstige Eventtypen.
</div>
</div>
</div>
)} />
)}
{/* 👥 Users (Admin+) */}
{isAdmin && (
<TabItemDirective header={{ text: '👥 Benutzer' }} content={() => (
<div style={{ padding: 20 }}>
<div className="e-card">
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Schnellaktionen</div>
</div>
</div>
<div className="e-card-content">
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<Link to="/benutzer"><ButtonComponent cssClass="e-primary">Benutzerverwaltung öffnen</ButtonComponent></Link>
<ButtonComponent disabled title="Demnächst">Benutzer einladen</ButtonComponent>
</div>
</div>
</div>
</div>
)} />
)}
{/* ⚙️ System (Superadmin) */}
{isSuperadmin && (
<TabItemDirective header={{ text: '⚙️ System' }} content={() => (
<div style={{ padding: 20 }}>
<div className="e-card" style={{ marginBottom: 20 }}>
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Organisationsinformationen</div>
</div>
</div>
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
Platzhalter für Organisationsname, Branding, Standard-Lokalisierung.
</div>
</div>
<div className="e-card">
<div className="e-card-header">
<div className="e-card-header-caption">
<div className="e-card-header-title">Erweiterte Konfiguration</div>
</div>
</div>
<div className="e-card-content" style={{ color: '#666', fontSize: 14 }}>
Platzhalter für System-weit fortgeschrittene Optionen.
</div>
</div>
</div>
)} />
)}
</TabItemsDirective>
</TabComponent>
</div> </div>
); );
}; };

View File

@@ -285,3 +285,23 @@ class Conversion(Base):
UniqueConstraint('source_event_media_id', 'target_format', UniqueConstraint('source_event_media_id', 'target_format',
'file_hash', name='uq_conv_source_target_hash'), 'file_hash', name='uq_conv_source_target_hash'),
) )
# --- SystemSetting: Flexible key-value store for system-wide configuration ---
class SystemSetting(Base):
__tablename__ = 'system_settings'
key = Column(String(100), primary_key=True, nullable=False)
value = Column(Text, nullable=True)
description = Column(String(255), nullable=True)
updated_at = Column(TIMESTAMP(timezone=True),
server_default=func.current_timestamp(),
onupdate=func.current_timestamp())
def to_dict(self):
return {
"key": self.key,
"value": self.value,
"description": self.description,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}

View File

@@ -0,0 +1,37 @@
"""add_system_settings_table
Revision ID: 045626c9719a
Revises: 488ce87c28ae
Create Date: 2025-10-16 18:38:47.415244
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '045626c9719a'
down_revision: Union[str, None] = '488ce87c28ae'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
op.create_table(
'system_settings',
sa.Column('key', sa.String(100), nullable=False),
sa.Column('value', sa.Text(), nullable=True),
sa.Column('description', sa.String(255), nullable=True),
sa.Column('updated_at', sa.TIMESTAMP(timezone=True),
server_default=sa.func.current_timestamp(),
nullable=True),
sa.PrimaryKeyConstraint('key')
)
def downgrade() -> None:
"""Downgrade schema."""
op.drop_table('system_settings')

View File

@@ -42,3 +42,25 @@ with engine.connect() as conn:
print(f"✅ Superadmin-Benutzer '{admin_user}' angelegt.") print(f"✅ Superadmin-Benutzer '{admin_user}' angelegt.")
else: else:
print(f" Superadmin-Benutzer '{admin_user}' existiert bereits.") print(f" Superadmin-Benutzer '{admin_user}' existiert bereits.")
# Default System Settings anlegen
default_settings = [
('supplement_table_url', '', 'URL für Vertretungsplan (Stundenplan-Änderungstabelle)'),
('supplement_table_enabled', 'false', 'Ob Vertretungsplan aktiviert ist'),
]
for key, value, description in default_settings:
result = conn.execute(
text("SELECT COUNT(*) FROM system_settings WHERE `key`=:key"),
{"key": key}
)
if result.scalar() == 0:
conn.execute(
text("INSERT INTO system_settings (`key`, value, description) VALUES (:key, :value, :description)"),
{"key": key, "value": value, "description": description}
)
print(f"✅ System-Einstellung '{key}' angelegt.")
else:
print(f" System-Einstellung '{key}' existiert bereits.")
print("✅ Initialisierung abgeschlossen.")

View File

@@ -122,24 +122,32 @@ def get_current_user():
db_session = Session() db_session = Session()
try: try:
user = db_session.query(User).filter_by(id=user_id).first() user = db_session.query(User).filter_by(id=user_id).first()
if not user: if not user:
# Session is stale, user was deleted # Session is stale, user was deleted
session.clear() session.clear()
return jsonify({"error": "Not authenticated"}), 401 return jsonify({"error": "Not authenticated"}), 401
if not user.is_active: if not user.is_active:
# User was deactivated # User was deactivated
session.clear() session.clear()
return jsonify({"error": "Account is disabled"}), 401 return jsonify({"error": "Account is disabled"}), 401
# For SQLAlchemy Enum(UserRole), ensure we return the string value
role_value = user.role.value if isinstance(user.role, UserRole) else str(user.role)
return jsonify({ return jsonify({
"id": user.id, "id": user.id,
"username": user.username, "username": user.username,
"role": user.role.value, "role": role_value,
"is_active": user.is_active "is_active": user.is_active
}), 200 }), 200
except Exception as e:
# Avoid naked 500s; return a JSON error with minimal info (safe in dev)
env = os.environ.get("ENV", "production").lower()
msg = str(e) if env in ("development", "dev") else "Internal server error"
return jsonify({"error": msg}), 500
finally: finally:
db_session.close() db_session.close()

View File

@@ -0,0 +1,203 @@
"""
System Settings API endpoints.
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 sqlalchemy.exc import SQLAlchemyError
system_settings_bp = Blueprint('system_settings', __name__, url_prefix='/api/system-settings')
@system_settings_bp.route('', methods=['GET'])
@admin_or_higher
def get_all_settings():
"""
Get all system settings.
Admin+ only.
"""
session = Session()
try:
settings = session.query(SystemSetting).all()
return jsonify({
'settings': [s.to_dict() for s in settings]
}), 200
except SQLAlchemyError as e:
return jsonify({'error': str(e)}), 500
finally:
session.close()
@system_settings_bp.route('/<key>', methods=['GET'])
@admin_or_higher
def get_setting(key):
"""
Get a specific system setting by key.
Admin+ only.
"""
session = Session()
try:
setting = session.query(SystemSetting).filter_by(key=key).first()
if not setting:
return jsonify({'error': 'Setting not found'}), 404
return jsonify(setting.to_dict()), 200
except SQLAlchemyError as e:
return jsonify({'error': str(e)}), 500
finally:
session.close()
@system_settings_bp.route('/<key>', methods=['POST', 'PUT'])
@admin_or_higher
def update_setting(key):
"""
Create or update a system setting.
Admin+ only.
Request body:
{
"value": "string",
"description": "string" (optional)
}
"""
session = Session()
try:
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
value = data.get('value')
description = data.get('description')
# Try to find existing setting
setting = session.query(SystemSetting).filter_by(key=key).first()
if setting:
# Update existing
setting.value = value
if description is not None:
setting.description = description
else:
# Create new
setting = SystemSetting(
key=key,
value=value,
description=description
)
session.add(setting)
session.commit()
return jsonify(setting.to_dict()), 200
except SQLAlchemyError as e:
session.rollback()
return jsonify({'error': str(e)}), 500
finally:
session.close()
@system_settings_bp.route('/<key>', methods=['DELETE'])
@admin_or_higher
def delete_setting(key):
"""
Delete a system setting.
Admin+ only.
"""
session = Session()
try:
setting = session.query(SystemSetting).filter_by(key=key).first()
if not setting:
return jsonify({'error': 'Setting not found'}), 404
session.delete(setting)
session.commit()
return jsonify({'message': 'Setting deleted successfully'}), 200
except SQLAlchemyError as e:
session.rollback()
return jsonify({'error': str(e)}), 500
finally:
session.close()
# Convenience endpoints for specific settings
@system_settings_bp.route('/supplement-table', methods=['GET'])
@admin_or_higher
def get_supplement_table_settings():
"""
Get supplement table URL and enabled status.
Admin+ only.
"""
session = Session()
try:
url_setting = session.query(SystemSetting).filter_by(key='supplement_table_url').first()
enabled_setting = session.query(SystemSetting).filter_by(key='supplement_table_enabled').first()
return jsonify({
'url': url_setting.value if url_setting else '',
'enabled': enabled_setting.value == 'true' if enabled_setting else False,
}), 200
except SQLAlchemyError as e:
return jsonify({'error': str(e)}), 500
finally:
session.close()
@system_settings_bp.route('/supplement-table', methods=['POST'])
@admin_or_higher
def update_supplement_table_settings():
"""
Update supplement table URL and enabled status.
Admin+ only.
Request body:
{
"url": "https://...",
"enabled": true/false
}
"""
session = Session()
try:
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
url = data.get('url', '')
enabled = data.get('enabled', False)
# Update or create URL setting
url_setting = session.query(SystemSetting).filter_by(key='supplement_table_url').first()
if url_setting:
url_setting.value = url
else:
url_setting = SystemSetting(
key='supplement_table_url',
value=url,
description='URL für Vertretungsplan (Stundenplan-Änderungstabelle)'
)
session.add(url_setting)
# Update or create enabled setting
enabled_setting = session.query(SystemSetting).filter_by(key='supplement_table_enabled').first()
if enabled_setting:
enabled_setting.value = 'true' if enabled else 'false'
else:
enabled_setting = SystemSetting(
key='supplement_table_enabled',
value='true' if enabled else 'false',
description='Ob Vertretungsplan aktiviert ist'
)
session.add(enabled_setting)
session.commit()
return jsonify({
'url': url,
'enabled': enabled,
'message': 'Supplement table settings updated successfully'
}), 200
except SQLAlchemyError as e:
session.rollback()
return jsonify({'error': str(e)}), 500
finally:
session.close()

View File

@@ -9,6 +9,7 @@ from server.routes.academic_periods import academic_periods_bp
from server.routes.groups import groups_bp from server.routes.groups import groups_bp
from server.routes.clients import clients_bp from server.routes.clients import clients_bp
from server.routes.auth import auth_bp from server.routes.auth import auth_bp
from server.routes.system_settings import system_settings_bp
from server.database import Session, engine from server.database import Session, engine
from flask import Flask, jsonify, send_from_directory, request from flask import Flask, jsonify, send_from_directory, request
import glob import glob
@@ -23,6 +24,7 @@ app = Flask(__name__)
app.config['SECRET_KEY'] = os.environ.get('FLASK_SECRET_KEY', 'dev-secret-key-change-in-production') app.config['SECRET_KEY'] = os.environ.get('FLASK_SECRET_KEY', 'dev-secret-key-change-in-production')
app.config['SESSION_COOKIE_HTTPONLY'] = True app.config['SESSION_COOKIE_HTTPONLY'] = True
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
app.register_blueprint(system_settings_bp)
# In production, set to True if using HTTPS # In production, set to True if using HTTPS
app.config['SESSION_COOKIE_SECURE'] = os.environ.get('ENV', 'development').lower() == 'production' app.config['SESSION_COOKIE_SECURE'] = os.environ.get('ENV', 'development').lower() == 'production'
# Session lifetime: longer in development for convenience # Session lifetime: longer in development for convenience