diff --git a/GPU25_26_mit_Herbstferien.TXT b/GPU25_26_mit_Herbstferien.TXT
new file mode 100644
index 0000000..2138b05
--- /dev/null
+++ b/GPU25_26_mit_Herbstferien.TXT
@@ -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 Empfngnis",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"
diff --git a/dashboard/public/program-info.json b/dashboard/public/program-info.json
index e4eebb6..3fda5e8 100644
--- a/dashboard/public/program-info.json
+++ b/dashboard/public/program-info.json
@@ -1,6 +1,6 @@
{
"appName": "Infoscreen-Management",
- "version": "2025.1.0-alpha.5",
+ "version": "2025.1.0-alpha.6",
"copyright": "© 2025 Third-Age-Applications",
"supportContact": "support@third-age-applications.com",
"description": "Eine zentrale Verwaltungsoberfläche für digitale Informationsbildschirme.",
@@ -26,10 +26,22 @@
]
},
"buildInfo": {
- "buildDate": "2025-08-30T12:00:00Z",
- "commitId": "a1b2c3d4e5f6"
+ "buildDate": "2025-09-20T11:00:00Z",
+ "commitId": "8d1df7199cb7"
},
"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",
"date": "2025-09-14",
diff --git a/dashboard/src/apiHolidays.ts b/dashboard/src/apiHolidays.ts
new file mode 100644
index 0000000..0298d0f
--- /dev/null
+++ b/dashboard/src/apiHolidays.ts
@@ -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 };
+}
diff --git a/dashboard/src/appointments.tsx b/dashboard/src/appointments.tsx
index 855a69d..9ae0b7b 100644
--- a/dashboard/src/appointments.tsx
+++ b/dashboard/src/appointments.tsx
@@ -144,7 +144,7 @@ const Appointments: React.FC = () => {
const [modalInitialData, setModalInitialData] = useState({});
const [schedulerKey, setSchedulerKey] = useState(0);
const [editMode, setEditMode] = useState(false); // NEU: Editiermodus
- const [showInactive, setShowInactive] = React.useState(false);
+ const [showInactive, setShowInactive] = React.useState(true);
// Gruppen laden
useEffect(() => {
@@ -413,7 +413,7 @@ const Appointments: React.FC = () => {
}
// Events nach Löschen neu laden
if (selectedGroupId) {
- fetchEvents(selectedGroupId)
+ fetchEvents(selectedGroupId, showInactive)
.then((data: RawEvent[]) => {
const mapped: Event[] = data.map((e: RawEvent) => ({
Id: e.Id,
diff --git a/dashboard/src/einstellungen.tsx b/dashboard/src/einstellungen.tsx
index c8ffcec..dc0461d 100644
--- a/dashboard/src/einstellungen.tsx
+++ b/dashboard/src/einstellungen.tsx
@@ -1,8 +1,83 @@
import React from 'react';
-const Einstellungen: React.FC = () => (
-
-
Einstellungen
-
Willkommen im Infoscreen-Management Einstellungen.
-
-);
+import { listHolidays, uploadHolidaysCsv, type Holiday } from './apiHolidays';
+
+const Einstellungen: React.FC = () => {
+ const [file, setFile] = React.useState(null);
+ const [busy, setBusy] = React.useState(false);
+ const [message, setMessage] = React.useState(null);
+ const [holidays, setHolidays] = React.useState([]);
+
+ 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 (
+
+
Einstellungen
+
+
+ Schulferien importieren
+
+ Laden Sie eine CSV-Datei mit den Spalten: name, start_date,{' '}
+ end_date, optional region.
+
+
+ setFile(e.target.files?.[0] ?? null)}
+ />
+
+
+ {message && {message}
}
+
+
+
+ Importierte Ferien
+ {holidays.length === 0 ? (
+ Keine Einträge vorhanden.
+ ) : (
+
+ {holidays.slice(0, 20).map(h => (
+ -
+ {h.name}: {h.start_date} – {h.end_date}
+ {h.region ? ` (${h.region})` : ''}
+
+ ))}
+
+ )}
+
+
+
+ );
+};
+
export default Einstellungen;
diff --git a/models/models.py b/models/models.py
index 55011ae..3f14e17 100644
--- a/models/models.py
+++ b/models/models.py
@@ -1,5 +1,5 @@
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
import enum
@@ -14,6 +14,12 @@ class UserRole(enum.Enum):
superadmin = "superadmin"
+class AcademicPeriodType(enum.Enum):
+ schuljahr = "schuljahr"
+ semester = "semester"
+ trimester = "trimester"
+
+
class User(Base):
__tablename__ = 'users'
id = Column(Integer, primary_key=True, autoincrement=True)
@@ -27,6 +33,42 @@ class User(Base):
), 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):
__tablename__ = 'client_groups'
id = Column(Integer, primary_key=True, autoincrement=True)
@@ -103,6 +145,9 @@ class Event(Base):
id = Column(Integer, primary_key=True, autoincrement=True)
group_id = Column(Integer, ForeignKey(
'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)
description = Column(Text, nullable=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)
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])
class EventMedia(Base):
__tablename__ = 'event_media'
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)
url = Column(String(255), nullable=False)
file_path = Column(String(255), nullable=True)
@@ -136,11 +186,44 @@ class EventMedia(Base):
uploaded_at = Column(TIMESTAMP, nullable=False,
default=lambda: datetime.now(timezone.utc))
+ # Add relationship
+ academic_period = relationship(
+ "AcademicPeriod", foreign_keys=[academic_period_id])
+
def to_dict(self):
return {
"id": self.id,
+ "academic_period_id": self.academic_period_id,
"media_type": self.media_type.value if self.media_type else None,
"url": self.url,
"file_path": self.file_path,
"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,
+ }
diff --git a/server/alembic/versions/71ba7ab08d84_merge_heads_after_holidays_table.py b/server/alembic/versions/71ba7ab08d84_merge_heads_after_holidays_table.py
new file mode 100644
index 0000000..008240c
--- /dev/null
+++ b/server/alembic/versions/71ba7ab08d84_merge_heads_after_holidays_table.py
@@ -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
diff --git a/server/alembic/versions/8d1df7199cb7_add_academic_periods_system.py b/server/alembic/versions/8d1df7199cb7_add_academic_periods_system.py
new file mode 100644
index 0000000..d8ddc6d
--- /dev/null
+++ b/server/alembic/versions/8d1df7199cb7_add_academic_periods_system.py
@@ -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 ###
diff --git a/server/alembic/versions/9b7a1f2a4d2b_add_school_holidays_table.py b/server/alembic/versions/9b7a1f2a4d2b_add_school_holidays_table.py
new file mode 100644
index 0000000..2f8e000
--- /dev/null
+++ b/server/alembic/versions/9b7a1f2a4d2b_add_school_holidays_table.py
@@ -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')
diff --git a/server/init_academic_periods.py b/server/init_academic_periods.py
new file mode 100644
index 0000000..3afa1a8
--- /dev/null
+++ b/server/init_academic_periods.py
@@ -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()
diff --git a/server/routes/holidays.py b/server/routes/holidays.py
new file mode 100644
index 0000000..e0b1b7d
--- /dev/null
+++ b/server/routes/holidays.py
@@ -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
diff --git a/server/wsgi.py b/server/wsgi.py
index 4fc7fc5..ff53ca6 100644
--- a/server/wsgi.py
+++ b/server/wsgi.py
@@ -2,6 +2,7 @@
from server.routes.eventmedia import eventmedia_bp
from server.routes.files import files_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.clients import clients_bp
from server.database import Session, engine
@@ -20,6 +21,7 @@ app.register_blueprint(groups_bp)
app.register_blueprint(events_bp)
app.register_blueprint(eventmedia_bp)
app.register_blueprint(files_bp)
+app.register_blueprint(holidays_bp)
@app.route("/health")