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:
@@ -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.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")
|
||||
|
||||
Reference in New Issue
Block a user