feat: 2026.1.0-alpha.16 – dashboard banner refactor, period auto-activation, text & docs

Dashboard (dashboard/src/dashboard.tsx, settings.tsx, apiAcademicPeriods.ts):
- Refactor loadHolidayStatus to useCallback with stable empty-deps reference;
  removes location.pathname dependency that caused overlapping API calls at mount
  and left the banner unresolved via request-sequence cancellation
- Add key prop derived from severity:text to Syncfusion MessageComponent to force
  remount on state change, fixing stale banner that ignored React prop/children updates
- Correct German transliterated text to proper Umlauts throughout visible UI strings
  (fuer -> für, oe -> ö, ae -> ä etc. across dashboard and settings views)

Backend (server/init_academic_periods.py):
- Refactor to idempotent two-phase flow: seed default periods only when table is
  empty; on every run activate exactly the non-archived period covering date.today()
- Enforces single-active invariant by deactivating all periods before promoting match
- Emits explicit warning when no period covers current date instead of doing nothing

Deployment (docker-compose.prod.yml):
- Add init_academic_periods.py to server startup chain after migrations and defaults;
  eliminates manual post-deploy step to set an active academic period

Release docs:
- program-info.json: bump to 2026.1.0-alpha.16; fix JSON parse error caused by
  typographic curly quotes in the new changelog entry
- TECH-CHANGELOG.md: detailed alpha.16 section with root-cause motivation for both
  dashboard refactoring decisions (unstable callback ref + Syncfusion stale render)
- DEV-CHANGELOG.md: document dashboard refactor, Syncfusion key fix, Umlaut changes,
  and program-info JSON regression and fix
- README.md: add Latest Release Highlights section for alpha.16
- .github/copilot-instructions.md: sync file map, prod bootstrap note, backend and
  frontend pattern additions for academic period init and Syncfusion remount pattern
This commit is contained in:
2026-04-02 14:16:53 +00:00
parent 06411edfab
commit 4d652f0554
10 changed files with 1054 additions and 888 deletions

View File

@@ -16,6 +16,8 @@ It is not a changelog and not a full architecture handbook.
- `scheduler/scheduler.py` - scheduler loop, MQTT event publishing, TV power intent publishing - `scheduler/scheduler.py` - scheduler loop, MQTT event publishing, TV power intent publishing
- `scheduler/db_utils.py` - event formatting and power-intent helper logic - `scheduler/db_utils.py` - event formatting and power-intent helper logic
- `listener/listener.py` - discovery/heartbeat/log/screenshot MQTT consumption - `listener/listener.py` - discovery/heartbeat/log/screenshot MQTT consumption
- `server/init_academic_periods.py` - idempotent academic-period seeding + auto-activation for current date
- `server/initialize_database.py` - migration + bootstrap orchestration for local/manual setup
- `server/routes/events.py` - event CRUD, recurrence handling, UTC normalization - `server/routes/events.py` - event CRUD, recurrence handling, UTC normalization
- `server/routes/eventmedia.py` - file manager, media upload/stream endpoints - `server/routes/eventmedia.py` - file manager, media upload/stream endpoints
- `server/routes/groups.py` - group lifecycle, alive status, order persistence - `server/routes/groups.py` - group lifecycle, alive status, order persistence
@@ -32,6 +34,7 @@ It is not a changelog and not a full architecture handbook.
- Listener: MQTT consumer that updates server-side state - Listener: MQTT consumer that updates server-side state
- Scheduler: publishes active events and group-level TV power intents - Scheduler: publishes active events and group-level TV power intents
- Nginx: routes `/api/*` and `/screenshots/*` to API, dashboard otherwise - Nginx: routes `/api/*` and `/screenshots/*` to API, dashboard otherwise
- Prod bootstrap: `docker-compose.prod.yml` server command runs migrations, defaults init, and academic-period init before Gunicorn start
## Non-negotiable conventions ## Non-negotiable conventions
- Datetime: - Datetime:
@@ -66,12 +69,14 @@ TV power intent Phase 1 rules:
- Keep enum/datetime serialization JSON-safe. - Keep enum/datetime serialization JSON-safe.
- Maintain UTC-safe comparisons in scheduler and routes. - Maintain UTC-safe comparisons in scheduler and routes.
- Keep recurrence handling backend-driven and consistent with exceptions. - Keep recurrence handling backend-driven and consistent with exceptions.
- Academic periods bootstrap is idempotent and should auto-activate period covering `date.today()` when available.
## Frontend patterns ## Frontend patterns
- Use Syncfusion-based patterns already present in dashboard. - Use Syncfusion-based patterns already present in dashboard.
- Keep API requests relative (`/api/...`) to use Vite proxy in dev. - Keep API requests relative (`/api/...`) to use Vite proxy in dev.
- Respect `FRONTEND_DESIGN_RULES.md` for component and styling conventions. - Respect `FRONTEND_DESIGN_RULES.md` for component and styling conventions.
- Keep role-gated UI behavior aligned with backend authorization. - Keep role-gated UI behavior aligned with backend authorization.
- Holiday status banner in dashboard should render from computed state and avoid stale message reuse in 3rd-party UI components.
## Environment variables (high-value) ## Environment variables (high-value)
- Scheduler: `POLL_INTERVAL_SECONDS`, `REFRESH_SECONDS` - Scheduler: `POLL_INTERVAL_SECONDS`, `REFRESH_SECONDS`

View File

@@ -5,6 +5,10 @@ This changelog tracks all changes made in the development workspace, including i
--- ---
## Unreleased (development workspace) ## Unreleased (development workspace)
- Programminfo GUI regression/fix: `dashboard/public/program-info.json` could not be loaded in Programminfo menu due to invalid JSON in the new alpha.16 changelog line (malformed quote in a text entry). Fixed JSON entry and verified file parses correctly again.
- Dashboard holiday banner fix: `dashboard/src/dashboard.tsx``loadHolidayStatus` now uses a stable `useCallback` with empty deps, preventing repeated re-creation on render. `useEffect` depends only on the stable callback reference.
- Dashboard Syncfusion stale-render fix: `MessageComponent` in the holiday banner now receives `key={`${severity}:${text}`}` to force remount when severity or text changes; without this Syncfusion cached stale DOM and the banner did not update reactively.
- Dashboard German text: Replaced transliterated forms (ae/oe/ue) with correct Umlauts throughout visible dashboard UI strings — `Präsentation`, `für`, `prüfen`, `Ferienüberschneidungen`, `verfügbar`, `Vorfälle`, `Ausfälle`.
- TV power intent (Phase 1): Scheduler publishes retained QoS1 group-level intents to `infoscreen/groups/{group_id}/power/intent` with transition+heartbeat semantics, startup/reconnect republish, and poll-based expiry (`max(3 × poll_interval_sec, 90s)`). - TV power intent (Phase 1): Scheduler publishes retained QoS1 group-level intents to `infoscreen/groups/{group_id}/power/intent` with transition+heartbeat semantics, startup/reconnect republish, and poll-based expiry (`max(3 × poll_interval_sec, 90s)`).
- TV power validation: Added unit/integration/canary coverage in `scheduler/test_power_intent_utils.py`, `scheduler/test_power_intent_scheduler.py`, and `test_power_intent_canary.py`. - TV power validation: Added unit/integration/canary coverage in `scheduler/test_power_intent_utils.py`, `scheduler/test_power_intent_scheduler.py`, and `test_power_intent_canary.py`.
- Monitoring system completion: End-to-end monitoring pipeline is active (MQTT logs/health → listener persistence → monitoring APIs → superadmin dashboard). - Monitoring system completion: End-to-end monitoring pipeline is active (MQTT logs/health → listener persistence → monitoring APIs → superadmin dashboard).

View File

@@ -15,6 +15,13 @@ Core stack:
- Messaging: MQTT (Mosquitto) - Messaging: MQTT (Mosquitto)
- Background jobs: Redis + RQ + Gotenberg - Background jobs: Redis + RQ + Gotenberg
## Latest Release Highlights (2026.1.0-alpha.16)
- Dashboard holiday status banner now updates reliably after hard refresh and after switching between settings and dashboard.
- Production startup now auto-initializes and auto-activates the academic period for the current date.
- Dashboard German UI wording was polished with proper Umlauts.
- User-facing changelog source: [dashboard/public/program-info.json](dashboard/public/program-info.json)
## Architecture (Short) ## Architecture (Short)
- Dashboard talks only to API (`/api/...` via Vite proxy in dev). - Dashboard talks only to API (`/api/...` via Vite proxy in dev).

View File

@@ -5,6 +5,29 @@
This changelog documents technical and developer-relevant changes included in public releases. For development workspace changes, see DEV-CHANGELOG.md. Not all changes here are reflected in the user-facing changelog (`program-info.json`), and not all UI/feature changes are repeated here. Some changes (e.g., backend refactoring, API adjustments, infrastructure, developer tooling, or internal logic) may only appear in TECH-CHANGELOG.md. For UI/feature changes, see `dashboard/public/program-info.json`. This changelog documents technical and developer-relevant changes included in public releases. For development workspace changes, see DEV-CHANGELOG.md. Not all changes here are reflected in the user-facing changelog (`program-info.json`), and not all UI/feature changes are repeated here. Some changes (e.g., backend refactoring, API adjustments, infrastructure, developer tooling, or internal logic) may only appear in TECH-CHANGELOG.md. For UI/feature changes, see `dashboard/public/program-info.json`.
## 2026.1.0-alpha.16 (2026-04-02)
- 🐛 **Dashboard holiday banner refactoring and state fix (`dashboard/src/dashboard.tsx`)**:
- **Motivation — unstable fetch function:** `loadHolidayStatus` had `location.pathname` in its `useCallback` dependency array, causing a new function reference to be created on every navigation event. The `useEffect` depending on that reference then re-fired, producing overlapping API calls at mount that cancelled each other via the request-sequence guard, leaving the banner unresolved.
- **Refactoring:** Removed `location.pathname` from `useCallback` deps (it was unused inside the function body). The callback now has an empty dependency array, making its reference stable across the component lifetime. The `useEffect` is keyed only to the stable callback reference — no spurious re-fires.
- **Motivation — Syncfusion stale render:** Syncfusion's `MessageComponent` caches its rendered DOM internally and does not reactively update when React passes new children or props. Even after React state changed, the component displayed whatever text was rendered on first mount.
- **Fix:** Added a `key` prop derived from `${severity}:${text}` to `MessageComponent`. React unmounts and remounts the component whenever the key changes, bypassing Syncfusion's internal caching and ensuring the correct message is always visible.
- **Result:** Active-period name and holiday overlap details now render correctly on hard refresh, initial load, and route transitions without additional API calls.
- 🗓️ **Academic period bootstrap hardening (`server/init_academic_periods.py`)**:
- Refactored initialization into idempotent flow:
- seed default periods only when table is empty,
- on every run, activate exactly the non-archived period covering `date.today()`.
- Enforces single-active behavior by deactivating all previously active periods before setting the period for today.
- Emits explicit warning if no period covers current date (all remain inactive), improving operational diagnostics.
- 🚀 **Production startup alignment (`docker-compose.prod.yml`)**:
- Server startup command now runs `python /app/server/init_academic_periods.py` after migrations and default settings bootstrap.
- Removes manual post-deploy step to set an active academic period.
- 🌐 **Dashboard UX/text refinement (`dashboard/src/dashboard.tsx`)**:
- Converted user-facing transliterated German strings to proper Umlauts in the dashboard (for example: "für", "prüfen", "Ferienüberschneidungen", "Vorfälle", "Ausfälle").
Notes for integrators:
- On production boot, the active period is now derived from current date coverage automatically.
- If customer calendars do not include today, startup logs a warning and dashboard banner will still guide admins to configure periods.
## 2026.1.0-alpha.15 (2026-03-31) ## 2026.1.0-alpha.15 (2026-03-31)
- 🔌 **TV Power Intent Phase 1 (server-side)**: - 🔌 **TV Power Intent Phase 1 (server-side)**:
- Scheduler now publishes retained QoS1 group-level intents to `infoscreen/groups/{group_id}/power/intent`. - Scheduler now publishes retained QoS1 group-level intents to `infoscreen/groups/{group_id}/power/intent`.

View File

@@ -1,6 +1,6 @@
{ {
"appName": "Infoscreen-Management", "appName": "Infoscreen-Management",
"version": "2026.1.0-alpha.15", "version": "2026.1.0-alpha.16",
"copyright": "© 2026 Third-Age-Applications", "copyright": "© 2026 Third-Age-Applications",
"supportContact": "support@third-age-applications.com", "supportContact": "support@third-age-applications.com",
"description": "Eine zentrale Verwaltungsoberfläche für digitale Informationsbildschirme.", "description": "Eine zentrale Verwaltungsoberfläche für digitale Informationsbildschirme.",
@@ -30,6 +30,16 @@
"commitId": "9f2ae8b44c3a" "commitId": "9f2ae8b44c3a"
}, },
"changelog": [ "changelog": [
{
"version": "2026.1.0-alpha.16",
"date": "2026-04-02",
"changes": [
"✅ Dashboard: Der Ferienstatus-Banner zeigt die aktive akademische Periode jetzt zuverlässig nach Hard-Refresh und beim Wechsel zwischen Dashboard und Einstellungen.",
"🧭 Navigation: Der Link vom Ferienstatus-Banner zu den Einstellungen bleibt stabil und funktioniert konsistent für Admin-Rollen.",
"🚀 Deployment: Akademische Perioden werden nach Initialisierung automatisch für das aktuelle Datum aktiviert (kein manueller Aktivierungsschritt direkt nach Rollout mehr nötig).",
"🔤 Sprache: Mehrere deutsche UI-Texte im Dashboard wurden auf korrekte Umlaute umgestellt (zum Beispiel für, prüfen, Vorfälle und Ausfälle)."
]
},
{ {
"version": "2026.1.0-alpha.15", "version": "2026.1.0-alpha.15",
"date": "2026-03-31", "date": "2026-03-31",

View File

@@ -19,8 +19,25 @@ export type PeriodUsage = {
blockers: string[]; blockers: string[];
}; };
function normalizeAcademicPeriod(period: any): AcademicPeriod {
return {
id: Number(period.id),
name: period.name,
displayName: period.displayName ?? period.display_name ?? null,
startDate: period.startDate ?? period.start_date,
endDate: period.endDate ?? period.end_date,
periodType: period.periodType ?? period.period_type,
isActive: Boolean(period.isActive ?? period.is_active),
isArchived: Boolean(period.isArchived ?? period.is_archived),
archivedAt: period.archivedAt ?? period.archived_at ?? null,
archivedBy: period.archivedBy ?? period.archived_by ?? null,
createdAt: period.createdAt ?? period.created_at,
updatedAt: period.updatedAt ?? period.updated_at,
};
}
async function api<T>(url: string, init?: RequestInit): Promise<T> { async function api<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, { credentials: 'include', ...init }); const res = await fetch(url, { credentials: 'include', cache: 'no-store', ...init });
if (!res.ok) { if (!res.ok) {
const text = await res.text(); const text = await res.text();
try { try {
@@ -35,10 +52,10 @@ async function api<T>(url: string, init?: RequestInit): Promise<T> {
export async function getAcademicPeriodForDate(date: Date): Promise<AcademicPeriod | null> { export async function getAcademicPeriodForDate(date: Date): Promise<AcademicPeriod | null> {
const iso = date.toISOString().slice(0, 10); const iso = date.toISOString().slice(0, 10);
const { period } = await api<{ period: AcademicPeriod | null }>( const { period } = await api<{ period: any | null }>(
`/api/academic_periods/for_date?date=${iso}` `/api/academic_periods/for_date?date=${iso}`
); );
return period ?? null; return period ? normalizeAcademicPeriod(period) : null;
} }
export async function listAcademicPeriods(options?: { export async function listAcademicPeriods(options?: {
@@ -53,20 +70,20 @@ export async function listAcademicPeriods(options?: {
params.set('archivedOnly', '1'); params.set('archivedOnly', '1');
} }
const query = params.toString(); const query = params.toString();
const { periods } = await api<{ periods: AcademicPeriod[] }>( const { periods } = await api<{ periods: any[] }>(
`/api/academic_periods${query ? `?${query}` : ''}` `/api/academic_periods${query ? `?${query}` : ''}`
); );
return Array.isArray(periods) ? periods : []; return Array.isArray(periods) ? periods.map(normalizeAcademicPeriod) : [];
} }
export async function getAcademicPeriod(id: number): Promise<AcademicPeriod> { export async function getAcademicPeriod(id: number): Promise<AcademicPeriod> {
const { period } = await api<{ period: AcademicPeriod }>(`/api/academic_periods/${id}`); const { period } = await api<{ period: any }>(`/api/academic_periods/${id}`);
return period; return normalizeAcademicPeriod(period);
} }
export async function getActiveAcademicPeriod(): Promise<AcademicPeriod | null> { export async function getActiveAcademicPeriod(): Promise<AcademicPeriod | null> {
const { period } = await api<{ period: AcademicPeriod | null }>(`/api/academic_periods/active`); const { period } = await api<{ period: any | null }>(`/api/academic_periods/active`);
return period ?? null; return period ? normalizeAcademicPeriod(period) : null;
} }
export async function createAcademicPeriod(payload: { export async function createAcademicPeriod(payload: {
@@ -76,12 +93,12 @@ export async function createAcademicPeriod(payload: {
endDate: string; endDate: string;
periodType: 'schuljahr' | 'semester' | 'trimester'; periodType: 'schuljahr' | 'semester' | 'trimester';
}): Promise<AcademicPeriod> { }): Promise<AcademicPeriod> {
const { period } = await api<{ period: AcademicPeriod }>(`/api/academic_periods`, { const { period } = await api<{ period: any }>(`/api/academic_periods`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
return period; return normalizeAcademicPeriod(period);
} }
export async function updateAcademicPeriod( export async function updateAcademicPeriod(
@@ -94,36 +111,36 @@ export async function updateAcademicPeriod(
periodType: 'schuljahr' | 'semester' | 'trimester'; periodType: 'schuljahr' | 'semester' | 'trimester';
}> }>
): Promise<AcademicPeriod> { ): Promise<AcademicPeriod> {
const { period } = await api<{ period: AcademicPeriod }>(`/api/academic_periods/${id}`, { const { period } = await api<{ period: any }>(`/api/academic_periods/${id}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
return period; return normalizeAcademicPeriod(period);
} }
export async function setActiveAcademicPeriod(id: number): Promise<AcademicPeriod> { export async function setActiveAcademicPeriod(id: number): Promise<AcademicPeriod> {
const { period } = await api<{ period: AcademicPeriod }>(`/api/academic_periods/${id}/activate`, { const { period } = await api<{ period: any }>(`/api/academic_periods/${id}/activate`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}); });
return period; return normalizeAcademicPeriod(period);
} }
export async function archiveAcademicPeriod(id: number): Promise<AcademicPeriod> { export async function archiveAcademicPeriod(id: number): Promise<AcademicPeriod> {
const { period } = await api<{ period: AcademicPeriod }>(`/api/academic_periods/${id}/archive`, { const { period } = await api<{ period: any }>(`/api/academic_periods/${id}/archive`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}); });
return period; return normalizeAcademicPeriod(period);
} }
export async function restoreAcademicPeriod(id: number): Promise<AcademicPeriod> { export async function restoreAcademicPeriod(id: number): Promise<AcademicPeriod> {
const { period } = await api<{ period: AcademicPeriod }>(`/api/academic_periods/${id}/restore`, { const { period } = await api<{ period: any }>(`/api/academic_periods/${id}/restore`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}); });
return period; return normalizeAcademicPeriod(period);
} }
export async function getAcademicPeriodUsage(id: number): Promise<PeriodUsage> { export async function getAcademicPeriodUsage(id: number): Promise<PeriodUsage> {

File diff suppressed because it is too large Load Diff

View File

@@ -21,12 +21,14 @@ import {
type PeriodUsage type PeriodUsage
} from './apiAcademicPeriods'; } from './apiAcademicPeriods';
import { formatIsoDateForDisplay } from './dateFormatting'; import { formatIsoDateForDisplay } from './dateFormatting';
import { Link } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
// Minimal event type for Syncfusion Tab 'selected' callback // Minimal event type for Syncfusion Tab 'selected' callback
type TabSelectedEvent = { selectedIndex?: number }; type TabSelectedEvent = { selectedIndex?: number };
const Einstellungen: React.FC = () => { const Einstellungen: React.FC = () => {
const location = useLocation();
// Presentation settings state // Presentation settings state
const [presentationInterval, setPresentationInterval] = React.useState(10); const [presentationInterval, setPresentationInterval] = React.useState(10);
const [presentationPageProgress, setPresentationPageProgress] = React.useState(true); const [presentationPageProgress, setPresentationPageProgress] = React.useState(true);
@@ -670,6 +672,8 @@ const Einstellungen: React.FC = () => {
const isAdmin = !!(user && ['admin', 'superadmin'].includes(user.role)); const isAdmin = !!(user && ['admin', 'superadmin'].includes(user.role));
const isSuperadmin = !!(user && user.role === 'superadmin'); const isSuperadmin = !!(user && user.role === 'superadmin');
const [rootTabIndex, setRootTabIndex] = React.useState(0);
// Preserve selected nested-tab indices to avoid resets on parent re-render // Preserve selected nested-tab indices to avoid resets on parent re-render
const [academicTabIndex, setAcademicTabIndex] = React.useState(0); const [academicTabIndex, setAcademicTabIndex] = React.useState(0);
const [displayTabIndex, setDisplayTabIndex] = React.useState(0); const [displayTabIndex, setDisplayTabIndex] = React.useState(0);
@@ -678,6 +682,22 @@ const Einstellungen: React.FC = () => {
const [usersTabIndex, setUsersTabIndex] = React.useState(0); const [usersTabIndex, setUsersTabIndex] = React.useState(0);
const [systemTabIndex, setSystemTabIndex] = React.useState(0); const [systemTabIndex, setSystemTabIndex] = React.useState(0);
React.useEffect(() => {
const params = new URLSearchParams(location.search);
const focus = params.get('focus');
if (focus === 'holidays') {
setRootTabIndex(0);
setAcademicTabIndex(1);
return;
}
if (focus === 'academic-periods') {
setRootTabIndex(0);
setAcademicTabIndex(0);
}
}, [location.search]);
// ---------- Leaf content functions (second-level tabs) ---------- // ---------- Leaf content functions (second-level tabs) ----------
// Academic Calendar // Academic Calendar
// (Old separate Import/List tab contents removed in favor of combined tab) // (Old separate Import/List tab contents removed in favor of combined tab)
@@ -1695,7 +1715,11 @@ const Einstellungen: React.FC = () => {
<h2 style={{ marginBottom: 20, fontSize: '24px', fontWeight: 600 }}>Einstellungen</h2> <h2 style={{ marginBottom: 20, fontSize: '24px', fontWeight: 600 }}>Einstellungen</h2>
<TabComponent heightAdjustMode="Auto"> <TabComponent
heightAdjustMode="Auto"
selectedItem={rootTabIndex}
selected={(e: TabSelectedEvent) => setRootTabIndex(e.selectedIndex ?? 0)}
>
<TabItemsDirective> <TabItemsDirective>
<TabItemDirective header={{ text: '📅 Akademischer Kalender' }} content={AcademicCalendarTabs} /> <TabItemDirective header={{ text: '📅 Akademischer Kalender' }} content={AcademicCalendarTabs} />
{isAdmin && ( {isAdmin && (

View File

@@ -94,6 +94,7 @@ services:
command: > command: >
bash -c "alembic -c /app/server/alembic.ini upgrade head && bash -c "alembic -c /app/server/alembic.ini upgrade head &&
python /app/server/init_defaults.py && python /app/server/init_defaults.py &&
python /app/server/init_academic_periods.py &&
exec gunicorn server.wsgi:app --bind 0.0.0.0:8000" exec gunicorn server.wsgi:app --bind 0.0.0.0:8000"
dashboard: dashboard:

View File

@@ -1,7 +1,11 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Erstellt Standard-Schuljahre für österreichische Schulen Erstellt Standard-Schuljahre und setzt automatisch die aktive Periode.
Führe dieses Skript nach der Migration aus, um Standard-Perioden zu erstellen.
Dieses Skript ist idempotent:
- Wenn keine Perioden existieren, werden Standard-Perioden erstellt.
- Danach wird (bei jedem Lauf) die nicht-archivierte Periode aktiviert,
die das heutige Datum abdeckt.
""" """
from datetime import date from datetime import date
@@ -11,54 +15,94 @@ import sys
sys.path.append('/workspace') sys.path.append('/workspace')
def _create_default_periods_if_missing(session):
"""Erstellt Standard-Schuljahre nur dann, wenn noch keine Perioden existieren."""
existing = session.query(AcademicPeriod).first()
if existing:
print("Academic periods already exist. Skipping creation.")
return False
periods = [
{
'name': 'Schuljahr 2024/25',
'display_name': 'SJ 24/25',
'start_date': date(2024, 9, 2),
'end_date': date(2025, 7, 4),
'period_type': AcademicPeriodType.schuljahr,
'is_active': False
},
{
'name': 'Schuljahr 2025/26',
'display_name': 'SJ 25/26',
'start_date': date(2025, 9, 1),
'end_date': date(2026, 7, 3),
'period_type': AcademicPeriodType.schuljahr,
'is_active': False
},
{
'name': 'Schuljahr 2026/27',
'display_name': 'SJ 26/27',
'start_date': date(2026, 9, 7),
'end_date': date(2027, 7, 2),
'period_type': AcademicPeriodType.schuljahr,
'is_active': False
}
]
for period_data in periods:
period = AcademicPeriod(**period_data)
session.add(period)
session.flush()
print(f"Successfully created {len(periods)} academic periods")
return True
def _activate_period_for_today(session):
"""Aktiviert genau eine Periode: die Periode, die heute abdeckt."""
today = date.today()
period_for_today = (
session.query(AcademicPeriod)
.filter(
AcademicPeriod.is_archived == False,
AcademicPeriod.start_date <= today,
AcademicPeriod.end_date >= today,
)
.order_by(AcademicPeriod.start_date.desc())
.first()
)
# Immer zunächst alle aktiven Perioden deaktivieren, um den Zustand konsistent zu halten.
session.query(AcademicPeriod).filter(AcademicPeriod.is_active == True).update(
{AcademicPeriod.is_active: False},
synchronize_session=False,
)
if period_for_today:
period_for_today.is_active = True
print(
f"Activated academic period for today ({today}): {period_for_today.name} "
f"[{period_for_today.start_date} - {period_for_today.end_date}]"
)
else:
print(
f"WARNING: No academic period covers today ({today}). "
"All periods remain inactive."
)
def create_default_academic_periods(): def create_default_academic_periods():
"""Erstellt Standard-Schuljahre für österreichische Schulen""" """Erstellt Standard-Perioden (falls nötig) und setzt aktive Periode für heute."""
session = Session() session = Session()
try: try:
# Prüfe ob bereits Perioden existieren _create_default_periods_if_missing(session)
existing = session.query(AcademicPeriod).first() _activate_period_for_today(session)
if existing:
print("Academic periods already exist. Skipping creation.")
return
# Standard Schuljahre erstellen
periods = [
{
'name': 'Schuljahr 2024/25',
'display_name': 'SJ 24/25',
'start_date': date(2024, 9, 2),
'end_date': date(2025, 7, 4),
'period_type': AcademicPeriodType.schuljahr,
'is_active': True # Aktuelles Schuljahr
},
{
'name': 'Schuljahr 2025/26',
'display_name': 'SJ 25/26',
'start_date': date(2025, 9, 1),
'end_date': date(2026, 7, 3),
'period_type': AcademicPeriodType.schuljahr,
'is_active': False
},
{
'name': 'Schuljahr 2026/27',
'display_name': 'SJ 26/27',
'start_date': date(2026, 9, 7),
'end_date': date(2027, 7, 2),
'period_type': AcademicPeriodType.schuljahr,
'is_active': False
}
]
for period_data in periods:
period = AcademicPeriod(**period_data)
session.add(period)
session.commit() session.commit()
print(f"Successfully created {len(periods)} academic periods")
# Zeige erstellte Perioden # Zeige erstellte Perioden
for period in session.query(AcademicPeriod).all(): for period in session.query(AcademicPeriod).order_by(AcademicPeriod.start_date.asc()).all():
status = "AKTIV" if period.is_active else "inaktiv" status = "AKTIV" if period.is_active else "inaktiv"
print( print(
f" - {period.name} ({period.start_date} - {period.end_date}) [{status}]") f" - {period.name} ({period.start_date} - {period.end_date}) [{status}]")