diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 25a9efe..fe9628b 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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`) - 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`. +- System settings: `system_settings` key–value 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). - 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`. - 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/`. + - System settings: `server/routes/system_settings.py` exposes key–value 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: - `GET /api/academic_periods` — list all periods - `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`). - “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: - 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. @@ -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`. - 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). + - 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. ## Production diff --git a/README.md b/README.md index c5b51f4..e834b05 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,7 @@ For detailed deployment instructions, see: - **Features**: Responsive design, real-time updates, file management - **Port**: 5173 (dev), served via Nginx (prod) - **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/`) - **Technology**: Flask + SQLAlchemy + Alembic @@ -285,6 +286,14 @@ mosquitto_sub -h localhost -t "infoscreen/+/heartbeat" -v - `POST /api/conversions/{media_id}/pdf` - Request conversion - `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 - `GET /health` - Service health check - `GET /api/screenshots/{uuid}.jpg` - Client screenshots @@ -315,7 +324,12 @@ mosquitto_sub -h localhost -t "infoscreen/+/heartbeat" -v - **Groups**: Client group organization - **Events**: Schedule management - **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 - **Program info**: Version, build info, tech stack and paginated changelog (reads `dashboard/public/program-info.json`) diff --git a/dashboard/public/program-info.json b/dashboard/public/program-info.json index e9ee1af..822fe32 100644 --- a/dashboard/public/program-info.json +++ b/dashboard/public/program-info.json @@ -1,6 +1,6 @@ { "appName": "Infoscreen-Management", - "version": "2025.1.0-alpha.10", + "version": "2025.1.0-alpha.11", "copyright": "© 2025 Third-Age-Applications", "supportContact": "support@third-age-applications.com", "description": "Eine zentrale Verwaltungsoberfläche für digitale Informationsbildschirme.", @@ -30,6 +30,17 @@ "commitId": "8d1df7199cb7" }, "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 /api‑Pfade über den Vite‑Proxy (verhindert CORS bzw. doppeltes /api).", + "📖 Doku: README zur Einstellungen-Seite (Tabs) und System-Settings-API ergänzt." + ] + }, { "version": "2025.1.0-alpha.10", "date": "2025-10-15", diff --git a/dashboard/src/apiSystemSettings.ts b/dashboard/src/apiSystemSettings.ts new file mode 100644 index 0000000..f0eb493 --- /dev/null +++ b/dashboard/src/apiSystemSettings.ts @@ -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 { + 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 { + 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 { + 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 { + 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(); +} diff --git a/dashboard/src/settings.tsx b/dashboard/src/settings.tsx index 2f6a325..5aba5ff 100644 --- a/dashboard/src/settings.tsx +++ b/dashboard/src/settings.tsx @@ -1,11 +1,46 @@ 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 { 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 { user } = useAuth(); + const toastRef = React.useRef(null); + + // Holidays state const [file, setFile] = React.useState(null); const [busy, setBusy] = React.useState(false); const [message, setMessage] = React.useState(null); const [holidays, setHolidays] = React.useState([]); + const [periods, setPeriods] = React.useState([]); + const [activePeriodId, setActivePeriodId] = React.useState(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 () => { try { @@ -14,12 +49,46 @@ const Einstellungen: React.FC = () => { } catch (e) { const msg = e instanceof Error ? e.message : 'Fehler beim Laden der Ferien'; 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(() => { 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 () => { if (!file) return; @@ -27,59 +96,375 @@ const Einstellungen: React.FC = () => { setMessage(null); try { 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(); } catch (e) { const msg = e instanceof Error ? e.message : 'Fehler beim Import.'; setMessage(msg); + showToast(msg, 'e-toast-danger'); } finally { setBusy(false); } }; - return ( -
-

Einstellungen

-
-
-

Schulferien importieren

-

- Unterstützte Formate: -
• CSV mit Kopfzeile: name, start_date,{' '} - end_date, optional region -
• TXT/CSV ohne Kopfzeile mit Spalten: interner Name, Name,{' '} - Start (YYYYMMDD), Ende (YYYYMMDD), optional interne - Info (ignoriert) -

-
- setFile(e.target.files?.[0] ?? null)} - /> - -
- {message &&
{message}
} -
+ const onSaveSupplementSettings = async () => { + setSupplementBusy(true); + try { + await updateSupplementTableSettings(supplementUrl, supplementEnabled); + showToast('Vertretungsplan-Einstellungen erfolgreich gespeichert', 'e-toast-success'); + } catch (e) { + const msg = e instanceof Error ? e.message : 'Fehler beim Speichern der Einstellungen'; + showToast(msg, 'e-toast-danger'); + } finally { + setSupplementBusy(false); + } + }; -
-

Importierte Ferien

- {holidays.length === 0 ? ( -
Keine Einträge vorhanden.
- ) : ( -
    - {holidays.slice(0, 20).map(h => ( -
  • - {h.name}: {h.start_date} – {h.end_date} - {h.region ? ` (${h.region})` : ''} -
  • - ))} -
+ const onTestSupplementUrl = () => { + if (supplementUrl) { + window.open(supplementUrl, '_blank'); + } else { + showToast('Bitte geben Sie zuerst eine URL ein', 'e-toast-warning'); + } + }; + + // Determine which tabs to show based on role + const isAdmin = !!(user && ['admin', 'superadmin'].includes(user.role)); + const isSuperadmin = !!(user && user.role === 'superadmin'); + + return ( +
+ + +

Einstellungen

+ + + + {/* 📅 Academic Calendar */} + ( +
+ {/* Holidays Import */} +
+
+
+
Schulferien importieren
+
+
+
+

+ Unterstützte Formate: +
• CSV mit Kopfzeile: name, start_date, end_date, optional region +
• TXT/CSV ohne Kopfzeile mit Spalten: interner Name, Name, Start (YYYYMMDD), Ende (YYYYMMDD), optional interne Info (ignoriert) +

+
+ setFile(e.target.files?.[0] ?? null)} /> + + {busy ? 'Importiere…' : 'CSV/TXT importieren'} + +
+ {message &&
{message}
} +
+
+ + {/* Imported Holidays List */} +
+
+
+
Importierte Ferien
+
+
+
+ {holidays.length === 0 ? ( +
Keine Einträge vorhanden.
+ ) : ( +
    + {holidays.slice(0, 20).map(h => ( +
  • + {h.name}: {h.start_date} – {h.end_date} + {h.region ? ` (${h.region})` : ''} +
  • + ))} +
+ )} +
+
+ + {/* Academic Periods */} +
+
+
+
Akademische Perioden
+
+
+
+ {periods.length === 0 ? ( +
Keine Perioden gefunden.
+ ) : ( +
+
+ setActivePeriodId(Number(e.value))} + placeholder="Aktive Periode wählen" + popupHeight="250px" + /> +
+ { + 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 + +
+ )} +
+
+
+ )} /> + + {/* 🖥️ Display & Clients (Admin+) */} + {isAdmin && ( + ( +
+
+
+
+
Standard-Einstellungen
+
+
+
+ Platzhalter für Anzeige-Defaults (z. B. Heartbeat-Timeout, Screenshot-Aufbewahrung, Standard-Gruppe). +
+
+
+
+
+
Client-Konfiguration
+
+
+
+
+ Infoscreen-Clients öffnen + Raumgruppen öffnen +
+
+
+
+ )} /> )} -
-
+ + {/* 🎬 Media & Files (Admin+) */} + {isAdmin && ( + ( +
+
+
+
+
Upload-Einstellungen
+
+
+
+ Platzhalter für Upload-Limits, erlaubte Dateitypen und Standardspeicherorte. +
+
+
+
+
+
Konvertierungsstatus
+
+
+
+ Platzhalter – Konversionsstatus-Übersicht kann hier integriert werden (siehe API /api/conversions/...). +
+
+
+ )} /> + )} + + {/* �️ Events (Admin+): per-event-type defaults and WebUntis link settings */} + {isAdmin && ( + ( +
+ {/* WebUntis / Supplement table URL */} +
+
+
+
WebUntis / Vertretungsplan
+
+
+
+
+ + setSupplementUrl(e.value || '')} + cssClass="e-outline" + width="100%" + /> +
+ Diese URL wird verwendet, um die Stundenplan-Änderungstabelle (Vertretungsplan) anzuzeigen. +
+
+ +
+ setSupplementEnabled(e.checked || false)} + /> +
+ +
+ + {supplementBusy ? 'Speichere…' : 'Einstellungen speichern'} + + + Vorschau öffnen + +
+
+
+ + {/* Presentation defaults */} +
+
+
+
Präsentationen
+
+
+
+ Platzhalter für Standardwerte (Autoplay, Loop, Intervall) für Präsentationen. +
+
+ + {/* Website defaults */} +
+
+
+
Webseiten
+
+
+
+ Platzhalter für Standardwerte (Lade-Timeout, Intervall, Sandbox/Embedding) für Webseiten. +
+
+ + {/* Video defaults */} +
+
+
+
Videos
+
+
+
+ Platzhalter für Standardwerte (Autoplay, Loop, Lautstärke) für Videos. +
+
+ + {/* Message defaults */} +
+
+
+
Mitteilungen
+
+
+
+ Platzhalter für Standardwerte (Dauer, Layout/Styling) für Mitteilungen. +
+
+ + {/* Other defaults */} +
+
+
+
Sonstige
+
+
+
+ Platzhalter für sonstige Eventtypen. +
+
+
+ )} /> + )} + + {/* 👥 Users (Admin+) */} + {isAdmin && ( + ( +
+
+
+
+
Schnellaktionen
+
+
+
+
+ Benutzerverwaltung öffnen + Benutzer einladen +
+
+
+
+ )} /> + )} + + {/* ⚙️ System (Superadmin) */} + {isSuperadmin && ( + ( +
+
+
+
+
Organisationsinformationen
+
+
+
+ Platzhalter für Organisationsname, Branding, Standard-Lokalisierung. +
+
+ +
+
+
+
Erweiterte Konfiguration
+
+
+
+ Platzhalter für System-weit fortgeschrittene Optionen. +
+
+
+ )} /> + )} + +
); }; diff --git a/models/models.py b/models/models.py index 99dd48d..0efb375 100644 --- a/models/models.py +++ b/models/models.py @@ -285,3 +285,23 @@ class Conversion(Base): UniqueConstraint('source_event_media_id', 'target_format', '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, + } diff --git a/server/alembic/versions/045626c9719a_add_system_settings_table.py b/server/alembic/versions/045626c9719a_add_system_settings_table.py new file mode 100644 index 0000000..40ba3ce --- /dev/null +++ b/server/alembic/versions/045626c9719a_add_system_settings_table.py @@ -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') diff --git a/server/init_defaults.py b/server/init_defaults.py index 4b1be69..407d00f 100644 --- a/server/init_defaults.py +++ b/server/init_defaults.py @@ -42,3 +42,25 @@ with engine.connect() as conn: print(f"✅ Superadmin-Benutzer '{admin_user}' angelegt.") else: 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.") diff --git a/server/routes/auth.py b/server/routes/auth.py index 48c5ed5..17af5bd 100644 --- a/server/routes/auth.py +++ b/server/routes/auth.py @@ -122,24 +122,32 @@ def get_current_user(): db_session = Session() try: user = db_session.query(User).filter_by(id=user_id).first() - + if not user: # Session is stale, user was deleted session.clear() return jsonify({"error": "Not authenticated"}), 401 - + if not user.is_active: # User was deactivated session.clear() 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({ "id": user.id, "username": user.username, - "role": user.role.value, + "role": role_value, "is_active": user.is_active }), 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: db_session.close() diff --git a/server/routes/system_settings.py b/server/routes/system_settings.py new file mode 100644 index 0000000..e4f1081 --- /dev/null +++ b/server/routes/system_settings.py @@ -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('/', 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('/', 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('/', 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() diff --git a/server/wsgi.py b/server/wsgi.py index e2a8680..6eb3dfd 100644 --- a/server/wsgi.py +++ b/server/wsgi.py @@ -9,6 +9,7 @@ from server.routes.academic_periods import academic_periods_bp from server.routes.groups import groups_bp from server.routes.clients import clients_bp from server.routes.auth import auth_bp +from server.routes.system_settings import system_settings_bp from server.database import Session, engine from flask import Flask, jsonify, send_from_directory, request 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['SESSION_COOKIE_HTTPONLY'] = True app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' +app.register_blueprint(system_settings_bp) # In production, set to True if using HTTPS app.config['SESSION_COOKIE_SECURE'] = os.environ.get('ENV', 'development').lower() == 'production' # Session lifetime: longer in development for convenience