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:
22
.github/copilot-instructions.md
vendored
22
.github/copilot-instructions.md
vendored
@@ -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
|
||||
|
||||
16
README.md
16
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`)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
108
dashboard/src/apiSystemSettings.ts
Normal file
108
dashboard/src/apiSystemSettings.ts
Normal 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();
|
||||
}
|
||||
@@ -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<ToastComponent>(null);
|
||||
|
||||
// Holidays state
|
||||
const [file, setFile] = React.useState<File | null>(null);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [message, setMessage] = React.useState<string | null>(null);
|
||||
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 () => {
|
||||
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 (
|
||||
<div>
|
||||
<h2 className="text-xl font-bold mb-4">Einstellungen</h2>
|
||||
<div className="space-y-4">
|
||||
<section className="p-4 border rounded-md">
|
||||
<h3 className="font-semibold mb-2">Schulferien importieren</h3>
|
||||
<p className="text-sm text-gray-600 mb-2">
|
||||
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 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>
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
<section className="p-4 border rounded-md">
|
||||
<h3 className="font-semibold mb-2">Importierte Ferien</h3>
|
||||
{holidays.length === 0 ? (
|
||||
<div className="text-sm text-gray-600">Keine Einträge vorhanden.</div>
|
||||
) : (
|
||||
<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}
|
||||
{h.region ? ` (${h.region})` : ''}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
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 (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
@@ -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.")
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
203
server/routes/system_settings.py
Normal file
203
server/routes/system_settings.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user