feat: Add academic periods system for educational institutions
- Add AcademicPeriod model with support for schuljahr/semester/trimester - Extend Event and EventMedia models with optional academic_period_id - Create Alembic migration (8d1df7199cb7) for academic periods system - Add init script for Austrian school year defaults (2024/25-2026/27) - Maintain full backward compatibility for existing events/media - Update program-info.json to version 2025.1.0-alpha.6 Database changes: - New academic_periods table with unique name constraint - Foreign key relationships with proper indexing - Support for multiple period types with single active period This lays the foundation for period-based organization of events and media content, specifically designed for school environments with future extensibility for universities.
This commit is contained in:
18
GPU25_26_mit_Herbstferien.TXT
Normal file
18
GPU25_26_mit_Herbstferien.TXT
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"2.11.","Allerseelen",20251102,20251102,
|
||||||
|
"Ferien2","Weihnachtsferien",20251224,20260106,
|
||||||
|
"Ferien3","Semesterferien",20260216,20260222,
|
||||||
|
"Ferien4_2","Osterferien",20260328,20260406,
|
||||||
|
"Ferien4","Hl. Florian",20260504,20260504,
|
||||||
|
"26.10.","Nationalfeiertag",20251026,20251026,"F"
|
||||||
|
"27.10.","Herbstferien",20251027,20251027,"F"
|
||||||
|
"28.10.","Herbstferien",20251028,20251028,"F"
|
||||||
|
"29.10.","Herbstferien",20251029,20251029,"F"
|
||||||
|
"30.10.","Herbstferien",20251030,20251030,"F"
|
||||||
|
"31.10.","Herbstferien",20251031,20251031,"F"
|
||||||
|
"1.11.","Allerheiligen",20251101,20251101,"F"
|
||||||
|
"8.12.","Mariä Empfängnis",20251208,20251208,"F"
|
||||||
|
"1.5.","Staatsfeiertag",20260501,20260501,"F"
|
||||||
|
"14.5.","Christi Himmelfahrt",20260514,20260514,"F"
|
||||||
|
"24.5.","Pfingstsonntag",20260524,20260524,"F"
|
||||||
|
"25.5.","Pfingstmontag",20260525,20260525,"F"
|
||||||
|
"4.6.","Fronleichnam",20260604,20260604,"F"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"appName": "Infoscreen-Management",
|
"appName": "Infoscreen-Management",
|
||||||
"version": "2025.1.0-alpha.5",
|
"version": "2025.1.0-alpha.6",
|
||||||
"copyright": "© 2025 Third-Age-Applications",
|
"copyright": "© 2025 Third-Age-Applications",
|
||||||
"supportContact": "support@third-age-applications.com",
|
"supportContact": "support@third-age-applications.com",
|
||||||
"description": "Eine zentrale Verwaltungsoberfläche für digitale Informationsbildschirme.",
|
"description": "Eine zentrale Verwaltungsoberfläche für digitale Informationsbildschirme.",
|
||||||
@@ -26,10 +26,22 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"buildInfo": {
|
"buildInfo": {
|
||||||
"buildDate": "2025-08-30T12:00:00Z",
|
"buildDate": "2025-09-20T11:00:00Z",
|
||||||
"commitId": "a1b2c3d4e5f6"
|
"commitId": "8d1df7199cb7"
|
||||||
},
|
},
|
||||||
"changelog": [
|
"changelog": [
|
||||||
|
{
|
||||||
|
"version": "2025.1.0-alpha.6",
|
||||||
|
"date": "2025-09-20",
|
||||||
|
"changes": [
|
||||||
|
"🗓️ NEU: Akademische Perioden System - Unterstützung für Schuljahre, Semester und Trimester",
|
||||||
|
"🏗️ DATENBANK: Neue 'academic_periods' Tabelle für zeitbasierte Organisation",
|
||||||
|
"🔗 ERWEITERT: Events und Medien können jetzt optional einer akademischen Periode zugeordnet werden",
|
||||||
|
"📊 ARCHITEKTUR: Vollständig rückwärtskompatible Implementierung für schrittweise Einführung",
|
||||||
|
"🎯 BILDUNG: Fokus auf Schulumgebung mit Erweiterbarkeit für Hochschulen",
|
||||||
|
"⚙️ TOOLS: Automatische Erstellung von Standard-Schuljahren für österreichische Schulen"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "2025.1.0-alpha.5",
|
"version": "2025.1.0-alpha.5",
|
||||||
"date": "2025-09-14",
|
"date": "2025-09-14",
|
||||||
|
|||||||
26
dashboard/src/apiHolidays.ts
Normal file
26
dashboard/src/apiHolidays.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
export type Holiday = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
start_date: string;
|
||||||
|
end_date: string;
|
||||||
|
region?: string | null;
|
||||||
|
source_file_name?: string | null;
|
||||||
|
imported_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listHolidays(region?: string) {
|
||||||
|
const url = region ? `/api/holidays?region=${encodeURIComponent(region)}` : '/api/holidays';
|
||||||
|
const res = await fetch(url);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Laden der Ferien');
|
||||||
|
return data as { holidays: Holiday[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadHolidaysCsv(file: File) {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
const res = await fetch('/api/holidays/upload', { method: 'POST', body: form });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok || data.error) throw new Error(data.error || 'Fehler beim Import der Ferien');
|
||||||
|
return data as { success: boolean; inserted: number; updated: number };
|
||||||
|
}
|
||||||
@@ -144,7 +144,7 @@ const Appointments: React.FC = () => {
|
|||||||
const [modalInitialData, setModalInitialData] = useState({});
|
const [modalInitialData, setModalInitialData] = useState({});
|
||||||
const [schedulerKey, setSchedulerKey] = useState(0);
|
const [schedulerKey, setSchedulerKey] = useState(0);
|
||||||
const [editMode, setEditMode] = useState(false); // NEU: Editiermodus
|
const [editMode, setEditMode] = useState(false); // NEU: Editiermodus
|
||||||
const [showInactive, setShowInactive] = React.useState(false);
|
const [showInactive, setShowInactive] = React.useState(true);
|
||||||
|
|
||||||
// Gruppen laden
|
// Gruppen laden
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -413,7 +413,7 @@ const Appointments: React.FC = () => {
|
|||||||
}
|
}
|
||||||
// Events nach Löschen neu laden
|
// Events nach Löschen neu laden
|
||||||
if (selectedGroupId) {
|
if (selectedGroupId) {
|
||||||
fetchEvents(selectedGroupId)
|
fetchEvents(selectedGroupId, showInactive)
|
||||||
.then((data: RawEvent[]) => {
|
.then((data: RawEvent[]) => {
|
||||||
const mapped: Event[] = data.map((e: RawEvent) => ({
|
const mapped: Event[] = data.map((e: RawEvent) => ({
|
||||||
Id: e.Id,
|
Id: e.Id,
|
||||||
|
|||||||
@@ -1,8 +1,83 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
const Einstellungen: React.FC = () => (
|
import { listHolidays, uploadHolidaysCsv, type Holiday } from './apiHolidays';
|
||||||
|
|
||||||
|
const Einstellungen: React.FC = () => {
|
||||||
|
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 refresh = React.useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await listHolidays();
|
||||||
|
setHolidays(data.holidays);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : 'Fehler beim Laden der Ferien';
|
||||||
|
setMessage(msg);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const onUpload = async () => {
|
||||||
|
if (!file) return;
|
||||||
|
setBusy(true);
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
const res = await uploadHolidaysCsv(file);
|
||||||
|
setMessage(`Import erfolgreich: ${res.inserted} neu, ${res.updated} aktualisiert.`);
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : 'Fehler beim Import.';
|
||||||
|
setMessage(msg);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-bold mb-4">Einstellungen</h2>
|
<h2 className="text-xl font-bold mb-4">Einstellungen</h2>
|
||||||
<p>Willkommen im Infoscreen-Management Einstellungen.</p>
|
<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">
|
||||||
|
Laden Sie eine CSV-Datei mit den Spalten: <code>name</code>, <code>start_date</code>,{' '}
|
||||||
|
<code>end_date</code>, optional <code>region</code>.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".csv,text/csv"
|
||||||
|
onChange={e => setFile(e.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
<button className="e-btn e-primary" onClick={onUpload} disabled={!file || busy}>
|
||||||
|
{busy ? 'Importiere…' : 'CSV importieren'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
{message && <div className="mt-2 text-sm">{message}</div>}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default Einstellungen;
|
export default Einstellungen;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
Column, Integer, String, Enum, TIMESTAMP, func, Boolean, ForeignKey, Float, Text, Index, DateTime
|
Column, Integer, String, Enum, TIMESTAMP, func, Boolean, ForeignKey, Float, Text, Index, DateTime, Date, UniqueConstraint
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import declarative_base, relationship
|
from sqlalchemy.orm import declarative_base, relationship
|
||||||
import enum
|
import enum
|
||||||
@@ -14,6 +14,12 @@ class UserRole(enum.Enum):
|
|||||||
superadmin = "superadmin"
|
superadmin = "superadmin"
|
||||||
|
|
||||||
|
|
||||||
|
class AcademicPeriodType(enum.Enum):
|
||||||
|
schuljahr = "schuljahr"
|
||||||
|
semester = "semester"
|
||||||
|
trimester = "trimester"
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
__tablename__ = 'users'
|
__tablename__ = 'users'
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
@@ -27,6 +33,42 @@ class User(Base):
|
|||||||
), onupdate=func.current_timestamp())
|
), onupdate=func.current_timestamp())
|
||||||
|
|
||||||
|
|
||||||
|
class AcademicPeriod(Base):
|
||||||
|
__tablename__ = 'academic_periods'
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
name = Column(String(100), nullable=False) # "Schuljahr 2024/25"
|
||||||
|
display_name = Column(String(50), nullable=True) # "SJ 24/25" (kurz)
|
||||||
|
start_date = Column(Date, nullable=False, index=True)
|
||||||
|
end_date = Column(Date, nullable=False, index=True)
|
||||||
|
period_type = Column(Enum(AcademicPeriodType),
|
||||||
|
nullable=False, default=AcademicPeriodType.schuljahr)
|
||||||
|
# nur eine aktive Periode zur Zeit
|
||||||
|
is_active = Column(Boolean, default=False, nullable=False)
|
||||||
|
created_at = Column(TIMESTAMP(timezone=True),
|
||||||
|
server_default=func.current_timestamp())
|
||||||
|
updated_at = Column(TIMESTAMP(timezone=True), server_default=func.current_timestamp(
|
||||||
|
), onupdate=func.current_timestamp())
|
||||||
|
|
||||||
|
# Constraint: nur eine aktive Periode zur Zeit
|
||||||
|
__table_args__ = (
|
||||||
|
Index('ix_academic_periods_active', 'is_active'),
|
||||||
|
UniqueConstraint('name', name='uq_academic_periods_name'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"name": self.name,
|
||||||
|
"display_name": self.display_name,
|
||||||
|
"start_date": self.start_date.isoformat() if self.start_date else None,
|
||||||
|
"end_date": self.end_date.isoformat() if self.end_date else None,
|
||||||
|
"period_type": self.period_type.value if self.period_type else None,
|
||||||
|
"is_active": self.is_active,
|
||||||
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
|
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class ClientGroup(Base):
|
class ClientGroup(Base):
|
||||||
__tablename__ = 'client_groups'
|
__tablename__ = 'client_groups'
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
@@ -103,6 +145,9 @@ class Event(Base):
|
|||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
group_id = Column(Integer, ForeignKey(
|
group_id = Column(Integer, ForeignKey(
|
||||||
'client_groups.id'), nullable=False, index=True)
|
'client_groups.id'), nullable=False, index=True)
|
||||||
|
academic_period_id = Column(Integer, ForeignKey(
|
||||||
|
# Optional für Rückwärtskompatibilität
|
||||||
|
'academic_periods.id'), nullable=True, index=True)
|
||||||
title = Column(String(100), nullable=False)
|
title = Column(String(100), nullable=False)
|
||||||
description = Column(Text, nullable=True)
|
description = Column(Text, nullable=True)
|
||||||
start = Column(TIMESTAMP(timezone=True), nullable=False, index=True)
|
start = Column(TIMESTAMP(timezone=True), nullable=False, index=True)
|
||||||
@@ -122,13 +167,18 @@ class Event(Base):
|
|||||||
updated_by = Column(Integer, ForeignKey('users.id'), nullable=True)
|
updated_by = Column(Integer, ForeignKey('users.id'), nullable=True)
|
||||||
is_active = Column(Boolean, default=True, nullable=False)
|
is_active = Column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
# Add relationship to EventMedia
|
# Add relationships
|
||||||
|
academic_period = relationship(
|
||||||
|
"AcademicPeriod", foreign_keys=[academic_period_id])
|
||||||
event_media = relationship("EventMedia", foreign_keys=[event_media_id])
|
event_media = relationship("EventMedia", foreign_keys=[event_media_id])
|
||||||
|
|
||||||
|
|
||||||
class EventMedia(Base):
|
class EventMedia(Base):
|
||||||
__tablename__ = 'event_media'
|
__tablename__ = 'event_media'
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
academic_period_id = Column(Integer, ForeignKey(
|
||||||
|
# Optional für bessere Organisation
|
||||||
|
'academic_periods.id'), nullable=True, index=True)
|
||||||
media_type = Column(Enum(MediaType), nullable=False)
|
media_type = Column(Enum(MediaType), nullable=False)
|
||||||
url = Column(String(255), nullable=False)
|
url = Column(String(255), nullable=False)
|
||||||
file_path = Column(String(255), nullable=True)
|
file_path = Column(String(255), nullable=True)
|
||||||
@@ -136,11 +186,44 @@ class EventMedia(Base):
|
|||||||
uploaded_at = Column(TIMESTAMP, nullable=False,
|
uploaded_at = Column(TIMESTAMP, nullable=False,
|
||||||
default=lambda: datetime.now(timezone.utc))
|
default=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
# Add relationship
|
||||||
|
academic_period = relationship(
|
||||||
|
"AcademicPeriod", foreign_keys=[academic_period_id])
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
return {
|
return {
|
||||||
"id": self.id,
|
"id": self.id,
|
||||||
|
"academic_period_id": self.academic_period_id,
|
||||||
"media_type": self.media_type.value if self.media_type else None,
|
"media_type": self.media_type.value if self.media_type else None,
|
||||||
"url": self.url,
|
"url": self.url,
|
||||||
"file_path": self.file_path,
|
"file_path": self.file_path,
|
||||||
"message_content": self.message_content,
|
"message_content": self.message_content,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SchoolHoliday(Base):
|
||||||
|
__tablename__ = 'school_holidays'
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
name = Column(String(150), nullable=False)
|
||||||
|
start_date = Column(Date, nullable=False, index=True)
|
||||||
|
end_date = Column(Date, nullable=False, index=True)
|
||||||
|
region = Column(String(100), nullable=True, index=True)
|
||||||
|
source_file_name = Column(String(255), nullable=True)
|
||||||
|
imported_at = Column(TIMESTAMP(timezone=True),
|
||||||
|
server_default=func.current_timestamp())
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint('name', 'start_date', 'end_date',
|
||||||
|
'region', name='uq_school_holidays_unique'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"name": self.name,
|
||||||
|
"start_date": self.start_date.isoformat() if self.start_date else None,
|
||||||
|
"end_date": self.end_date.isoformat() if self.end_date else None,
|
||||||
|
"region": self.region,
|
||||||
|
"source_file_name": self.source_file_name,
|
||||||
|
"imported_at": self.imported_at.isoformat() if self.imported_at else None,
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""merge heads after holidays table
|
||||||
|
|
||||||
|
Revision ID: 71ba7ab08d84
|
||||||
|
Revises: 216402147826, 9b7a1f2a4d2b
|
||||||
|
Create Date: 2025-09-18 19:04:12.755422
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '71ba7ab08d84'
|
||||||
|
down_revision: Union[str, None] = ('216402147826', '9b7a1f2a4d2b')
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
pass
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""add academic periods system
|
||||||
|
|
||||||
|
Revision ID: 8d1df7199cb7
|
||||||
|
Revises: 71ba7ab08d84
|
||||||
|
Create Date: 2025-09-20 11:07:08.059374
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '8d1df7199cb7'
|
||||||
|
down_revision: Union[str, None] = '71ba7ab08d84'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('academic_periods',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('display_name', sa.String(length=50), nullable=True),
|
||||||
|
sa.Column('start_date', sa.Date(), nullable=False),
|
||||||
|
sa.Column('end_date', sa.Date(), nullable=False),
|
||||||
|
sa.Column('period_type', sa.Enum('schuljahr', 'semester', 'trimester', name='academicperiodtype'), nullable=False),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('name', name='uq_academic_periods_name')
|
||||||
|
)
|
||||||
|
op.create_index('ix_academic_periods_active', 'academic_periods', ['is_active'], unique=False)
|
||||||
|
op.create_index(op.f('ix_academic_periods_end_date'), 'academic_periods', ['end_date'], unique=False)
|
||||||
|
op.create_index(op.f('ix_academic_periods_start_date'), 'academic_periods', ['start_date'], unique=False)
|
||||||
|
op.add_column('event_media', sa.Column('academic_period_id', sa.Integer(), nullable=True))
|
||||||
|
op.create_index(op.f('ix_event_media_academic_period_id'), 'event_media', ['academic_period_id'], unique=False)
|
||||||
|
op.create_foreign_key(None, 'event_media', 'academic_periods', ['academic_period_id'], ['id'])
|
||||||
|
op.add_column('events', sa.Column('academic_period_id', sa.Integer(), nullable=True))
|
||||||
|
op.create_index(op.f('ix_events_academic_period_id'), 'events', ['academic_period_id'], unique=False)
|
||||||
|
op.create_foreign_key(None, 'events', 'academic_periods', ['academic_period_id'], ['id'])
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_constraint(None, 'events', type_='foreignkey')
|
||||||
|
op.drop_index(op.f('ix_events_academic_period_id'), table_name='events')
|
||||||
|
op.drop_column('events', 'academic_period_id')
|
||||||
|
op.drop_constraint(None, 'event_media', type_='foreignkey')
|
||||||
|
op.drop_index(op.f('ix_event_media_academic_period_id'), table_name='event_media')
|
||||||
|
op.drop_column('event_media', 'academic_period_id')
|
||||||
|
op.drop_index(op.f('ix_academic_periods_start_date'), table_name='academic_periods')
|
||||||
|
op.drop_index(op.f('ix_academic_periods_end_date'), table_name='academic_periods')
|
||||||
|
op.drop_index('ix_academic_periods_active', table_name='academic_periods')
|
||||||
|
op.drop_table('academic_periods')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""add school holidays table
|
||||||
|
|
||||||
|
Revision ID: 9b7a1f2a4d2b
|
||||||
|
Revises: e6eaede720aa
|
||||||
|
Create Date: 2025-09-18 00:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '9b7a1f2a4d2b'
|
||||||
|
down_revision = 'e6eaede720aa'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'school_holidays',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column('name', sa.String(length=150), nullable=False),
|
||||||
|
sa.Column('start_date', sa.Date(), nullable=False),
|
||||||
|
sa.Column('end_date', sa.Date(), nullable=False),
|
||||||
|
sa.Column('region', sa.String(length=100), nullable=True),
|
||||||
|
sa.Column('source_file_name', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('imported_at', sa.TIMESTAMP(timezone=True),
|
||||||
|
server_default=sa.text('CURRENT_TIMESTAMP')),
|
||||||
|
)
|
||||||
|
op.create_index('ix_school_holidays_start_date',
|
||||||
|
'school_holidays', ['start_date'])
|
||||||
|
op.create_index('ix_school_holidays_end_date',
|
||||||
|
'school_holidays', ['end_date'])
|
||||||
|
op.create_index('ix_school_holidays_region', 'school_holidays', ['region'])
|
||||||
|
op.create_unique_constraint('uq_school_holidays_unique', 'school_holidays', [
|
||||||
|
'name', 'start_date', 'end_date', 'region'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_constraint('uq_school_holidays_unique',
|
||||||
|
'school_holidays', type_='unique')
|
||||||
|
op.drop_index('ix_school_holidays_region', table_name='school_holidays')
|
||||||
|
op.drop_index('ix_school_holidays_end_date', table_name='school_holidays')
|
||||||
|
op.drop_index('ix_school_holidays_start_date',
|
||||||
|
table_name='school_holidays')
|
||||||
|
op.drop_table('school_holidays')
|
||||||
74
server/init_academic_periods.py
Normal file
74
server/init_academic_periods.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Erstellt Standard-Schuljahre für österreichische Schulen
|
||||||
|
Führe dieses Skript nach der Migration aus, um Standard-Perioden zu erstellen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from models.models import AcademicPeriod, AcademicPeriodType
|
||||||
|
from server.database import Session
|
||||||
|
import sys
|
||||||
|
sys.path.append('/workspace')
|
||||||
|
|
||||||
|
|
||||||
|
def create_default_academic_periods():
|
||||||
|
"""Erstellt Standard-Schuljahre für österreichische Schulen"""
|
||||||
|
session = Session()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Prüfe ob bereits Perioden existieren
|
||||||
|
existing = session.query(AcademicPeriod).first()
|
||||||
|
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()
|
||||||
|
print(f"Successfully created {len(periods)} academic periods")
|
||||||
|
|
||||||
|
# Zeige erstellte Perioden
|
||||||
|
for period in session.query(AcademicPeriod).all():
|
||||||
|
status = "AKTIV" if period.is_active else "inaktiv"
|
||||||
|
print(
|
||||||
|
f" - {period.name} ({period.start_date} - {period.end_date}) [{status}]")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
session.rollback()
|
||||||
|
print(f"Error creating academic periods: {e}")
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
create_default_academic_periods()
|
||||||
109
server/routes/holidays.py
Normal file
109
server/routes/holidays.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
from server.database import Session
|
||||||
|
from models.models import SchoolHoliday
|
||||||
|
from datetime import datetime
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
|
||||||
|
holidays_bp = Blueprint("holidays", __name__, url_prefix="/api/holidays")
|
||||||
|
|
||||||
|
|
||||||
|
@holidays_bp.route("", methods=["GET"])
|
||||||
|
def list_holidays():
|
||||||
|
session = Session()
|
||||||
|
region = request.args.get("region")
|
||||||
|
q = session.query(SchoolHoliday)
|
||||||
|
if region:
|
||||||
|
q = q.filter(SchoolHoliday.region == region)
|
||||||
|
rows = q.order_by(SchoolHoliday.start_date.asc()).all()
|
||||||
|
data = [r.to_dict() for r in rows]
|
||||||
|
session.close()
|
||||||
|
return jsonify({"holidays": data})
|
||||||
|
|
||||||
|
|
||||||
|
@holidays_bp.route("/upload", methods=["POST"])
|
||||||
|
def upload_holidays():
|
||||||
|
"""
|
||||||
|
Accepts a CSV file upload (multipart/form-data) with columns like:
|
||||||
|
name,start_date,end_date,region
|
||||||
|
Dates can be in ISO (YYYY-MM-DD) or common European format (DD.MM.YYYY).
|
||||||
|
"""
|
||||||
|
if "file" not in request.files:
|
||||||
|
return jsonify({"error": "No file part"}), 400
|
||||||
|
file = request.files["file"]
|
||||||
|
if file.filename == "":
|
||||||
|
return jsonify({"error": "No selected file"}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = file.read().decode("utf-8", errors="ignore")
|
||||||
|
# Try to auto-detect delimiter; default ','
|
||||||
|
sniffer = csv.Sniffer()
|
||||||
|
dialect = None
|
||||||
|
try:
|
||||||
|
dialect = sniffer.sniff(content[:1024])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
reader = csv.DictReader(io.StringIO(
|
||||||
|
content), dialect=dialect) if dialect else csv.DictReader(io.StringIO(content))
|
||||||
|
|
||||||
|
required = {"name", "start_date", "end_date"}
|
||||||
|
if not required.issubset(set(h.lower() for h in reader.fieldnames or [])):
|
||||||
|
return jsonify({"error": "CSV must contain headers: name, start_date, end_date"}), 400
|
||||||
|
|
||||||
|
def parse_date(s: str):
|
||||||
|
s = (s or "").strip()
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
# Try ISO first
|
||||||
|
for fmt in ("%Y-%m-%d", "%d.%m.%Y", "%Y/%m/%d"):
|
||||||
|
try:
|
||||||
|
return datetime.strptime(s, fmt).date()
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
raise ValueError(f"Unsupported date format: {s}")
|
||||||
|
|
||||||
|
session = Session()
|
||||||
|
inserted = 0
|
||||||
|
updated = 0
|
||||||
|
for row in reader:
|
||||||
|
# Normalize headers to lower-case keys
|
||||||
|
norm = {k.lower(): (v or "").strip() for k, v in row.items()}
|
||||||
|
name = norm.get("name")
|
||||||
|
start_date = parse_date(norm.get("start_date"))
|
||||||
|
end_date = parse_date(norm.get("end_date"))
|
||||||
|
region = norm.get("region") or None
|
||||||
|
if not name or not start_date or not end_date:
|
||||||
|
continue
|
||||||
|
|
||||||
|
existing = (
|
||||||
|
session.query(SchoolHoliday)
|
||||||
|
.filter(
|
||||||
|
SchoolHoliday.name == name,
|
||||||
|
SchoolHoliday.start_date == start_date,
|
||||||
|
SchoolHoliday.end_date == end_date,
|
||||||
|
SchoolHoliday.region.is_(
|
||||||
|
region) if region is None else SchoolHoliday.region == region,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
# Optionally update region or source_file_name
|
||||||
|
existing.region = region
|
||||||
|
existing.source_file_name = file.filename
|
||||||
|
updated += 1
|
||||||
|
else:
|
||||||
|
session.add(SchoolHoliday(
|
||||||
|
name=name,
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
region=region,
|
||||||
|
source_file_name=file.filename,
|
||||||
|
))
|
||||||
|
inserted += 1
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
session.close()
|
||||||
|
return jsonify({"success": True, "inserted": inserted, "updated": updated})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 400
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
from server.routes.eventmedia import eventmedia_bp
|
from server.routes.eventmedia import eventmedia_bp
|
||||||
from server.routes.files import files_bp
|
from server.routes.files import files_bp
|
||||||
from server.routes.events import events_bp
|
from server.routes.events import events_bp
|
||||||
|
from server.routes.holidays import holidays_bp
|
||||||
from server.routes.groups import groups_bp
|
from server.routes.groups import groups_bp
|
||||||
from server.routes.clients import clients_bp
|
from server.routes.clients import clients_bp
|
||||||
from server.database import Session, engine
|
from server.database import Session, engine
|
||||||
@@ -20,6 +21,7 @@ app.register_blueprint(groups_bp)
|
|||||||
app.register_blueprint(events_bp)
|
app.register_blueprint(events_bp)
|
||||||
app.register_blueprint(eventmedia_bp)
|
app.register_blueprint(eventmedia_bp)
|
||||||
app.register_blueprint(files_bp)
|
app.register_blueprint(files_bp)
|
||||||
|
app.register_blueprint(holidays_bp)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/health")
|
@app.route("/health")
|
||||||
|
|||||||
Reference in New Issue
Block a user