feat: period-scoped holiday management, archive lifecycle, and docs/release sync

- add period-scoped holiday architecture end-to-end
	- model: scope `SchoolHoliday` to `academic_period_id`
	- migrations: add holiday-period scoping, academic-period archive lifecycle, and merge migration head
	- API: extend holidays with manual CRUD, period validation, duplicate prevention, and overlap merge/conflict handling
	- recurrence: regenerate holiday exceptions using period-scoped holiday sets

- improve frontend settings and holiday workflows
	- bind holiday import/list/manual CRUD to selected academic period
	- show detailed import outcomes (inserted/updated/merged/skipped/conflicts)
	- fix file-picker UX (visible selected filename)
	- align settings controls/dialogs with defined frontend design rules
	- scope appointments/dashboard holiday loading to active period
	- add shared date formatting utility

- strengthen academic period lifecycle handling
	- add archive/restore/delete flow and backend validations/blocker checks
	- extend API client support for lifecycle operations

- release/docs updates and cleanup
	- bump user-facing version to `2026.1.0-alpha.15` with new changelog entry
	- add tech changelog entry for alpha.15 backend changes
	- refactor README to concise index and archive historical implementation docs
	- fix Copilot instruction link diagnostics via local `.github` design-rules reference
This commit is contained in:
2026-03-31 12:25:55 +00:00
parent 2580aa5e0d
commit b5f5f30005
23 changed files with 2940 additions and 897 deletions

View File

@@ -1,25 +1,203 @@
from flask import Blueprint, request, jsonify
from server.permissions import admin_or_higher
from server.database import Session
from models.models import SchoolHoliday
from datetime import datetime
from models.models import AcademicPeriod, SchoolHoliday, Event, EventException
from datetime import datetime, date, timedelta
from sqlalchemy import func
from sqlalchemy.exc import IntegrityError
import csv
import io
holidays_bp = Blueprint("holidays", __name__, url_prefix="/api/holidays")
def _regenerate_for_period(session, academic_period_id) -> int:
"""Re-generate holiday skip exceptions for all skip_holidays recurring events in the period."""
from dateutil.rrule import rrulestr
from dateutil.tz import UTC
q = session.query(Event).filter(
Event.skip_holidays == True, # noqa: E712
Event.recurrence_rule.isnot(None),
)
if academic_period_id is not None:
q = q.filter(Event.academic_period_id == academic_period_id)
else:
q = q.filter(Event.academic_period_id.is_(None))
events = q.all()
hq = session.query(SchoolHoliday)
if academic_period_id is not None:
hq = hq.filter(SchoolHoliday.academic_period_id == academic_period_id)
else:
hq = hq.filter(SchoolHoliday.academic_period_id.is_(None))
holidays = hq.all()
holiday_dates = set()
for h in holidays:
d = h.start_date
while d <= h.end_date:
holiday_dates.add(d)
d = d + timedelta(days=1)
for ev in events:
session.query(EventException).filter(
EventException.event_id == ev.id,
EventException.is_skipped == True, # noqa: E712
EventException.override_title.is_(None),
EventException.override_description.is_(None),
EventException.override_start.is_(None),
EventException.override_end.is_(None),
).delete(synchronize_session=False)
if not holiday_dates:
continue
try:
dtstart = ev.start.astimezone(UTC)
r = rrulestr(ev.recurrence_rule, dtstart=dtstart)
window_start = dtstart
window_end = (
ev.recurrence_end.astimezone(UTC)
if ev.recurrence_end
else dtstart.replace(year=dtstart.year + 1)
)
for occ_start in r.between(window_start, window_end, inc=True):
occ_date = occ_start.date()
if occ_date in holiday_dates:
session.add(EventException(
event_id=ev.id,
exception_date=occ_date,
is_skipped=True,
))
except Exception:
pass # malformed recurrence rule — skip silently
return len(events)
def _parse_academic_period_id(raw_value):
if raw_value in (None, ""):
return None
try:
return int(raw_value)
except (TypeError, ValueError) as exc:
raise ValueError("Invalid academicPeriodId") from exc
def _validate_holiday_dates_within_period(period, start_date, end_date, label="Ferienblock"):
if period is None or start_date is None or end_date is None:
return
if start_date < period.start_date or end_date > period.end_date:
period_name = period.display_name or period.name
raise ValueError(
f"{label} liegt außerhalb der akademischen Periode \"{period_name}\" "
f"({period.start_date.isoformat()} bis {period.end_date.isoformat()})"
)
def _normalize_optional_text(value):
normalized = (value or "").strip()
return normalized or None
def _apply_period_filter(query, academic_period_id):
if academic_period_id is None:
return query.filter(SchoolHoliday.academic_period_id.is_(None))
return query.filter(SchoolHoliday.academic_period_id == academic_period_id)
def _identity_key(name, region):
normalized_name = _normalize_optional_text(name) or ""
normalized_region = _normalize_optional_text(region) or ""
return normalized_name.casefold(), normalized_region.casefold()
def _is_same_identity(holiday, name, region):
return _identity_key(holiday.name, holiday.region) == _identity_key(name, region)
def _find_overlapping_holidays(session, academic_period_id, start_date, end_date, exclude_id=None):
query = _apply_period_filter(session.query(SchoolHoliday), academic_period_id).filter(
SchoolHoliday.start_date <= end_date + timedelta(days=1),
SchoolHoliday.end_date >= start_date - timedelta(days=1),
)
if exclude_id is not None:
query = query.filter(SchoolHoliday.id != exclude_id)
return query.order_by(SchoolHoliday.start_date.asc(), SchoolHoliday.id.asc()).all()
def _split_overlap_candidates(overlaps, name, region):
same_identity = [holiday for holiday in overlaps if _is_same_identity(holiday, name, region)]
conflicts = [holiday for holiday in overlaps if not _is_same_identity(holiday, name, region)]
return same_identity, conflicts
def _merge_holiday_group(session, keeper, others, name, start_date, end_date, region, source_file_name=None):
all_starts = [start_date, keeper.start_date, *[holiday.start_date for holiday in others]]
all_ends = [end_date, keeper.end_date, *[holiday.end_date for holiday in others]]
keeper.name = _normalize_optional_text(name) or keeper.name
keeper.region = _normalize_optional_text(region)
keeper.start_date = min(all_starts)
keeper.end_date = max(all_ends)
if source_file_name is not None:
keeper.source_file_name = source_file_name
for holiday in others:
session.delete(holiday)
return keeper
def _format_overlap_conflict(label, conflicts):
conflict_labels = ", ".join(
f'{holiday.name} ({holiday.start_date.isoformat()} bis {holiday.end_date.isoformat()})'
for holiday in conflicts[:3]
)
suffix = "" if len(conflicts) <= 3 else f" und {len(conflicts) - 3} weitere"
return f"{label} überschneidet sich mit bestehenden Ferienblöcken: {conflict_labels}{suffix}"
def _find_duplicate_holiday(session, academic_period_id, name, start_date, end_date, region, exclude_id=None):
normalized_name = _normalize_optional_text(name)
normalized_region = _normalize_optional_text(region)
query = session.query(SchoolHoliday).filter(
func.lower(SchoolHoliday.name) == normalized_name.casefold(),
SchoolHoliday.start_date == start_date,
SchoolHoliday.end_date == end_date,
)
query = _apply_period_filter(query, academic_period_id)
if normalized_region is None:
query = query.filter(SchoolHoliday.region.is_(None))
else:
query = query.filter(func.lower(SchoolHoliday.region) == normalized_region.casefold())
if exclude_id is not None:
query = query.filter(SchoolHoliday.id != exclude_id)
return query.first()
@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})
try:
region = request.args.get("region")
academic_period_id = _parse_academic_period_id(
request.args.get("academicPeriodId") or request.args.get("academic_period_id")
)
q = session.query(SchoolHoliday)
if region:
q = q.filter(SchoolHoliday.region == region)
if academic_period_id is not None:
q = q.filter(SchoolHoliday.academic_period_id == academic_period_id)
rows = q.order_by(SchoolHoliday.start_date.asc(), SchoolHoliday.end_date.asc()).all()
data = [r.to_dict() for r in rows]
return jsonify({"holidays": data})
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
finally:
session.close()
@holidays_bp.route("/upload", methods=["POST"])
@@ -41,6 +219,7 @@ def upload_holidays():
if file.filename == "":
return jsonify({"error": "No selected file"}), 400
session = Session()
try:
raw = file.read()
# Try UTF-8 first (strict), then cp1252, then latin-1 as last resort
@@ -79,9 +258,35 @@ def upload_holidays():
continue
raise ValueError(f"Unsupported date format: {s}")
session = Session()
academic_period_id = _parse_academic_period_id(
request.form.get("academicPeriodId") or request.form.get("academic_period_id")
)
period = None
if academic_period_id is not None:
period = session.query(AcademicPeriod).get(academic_period_id)
if not period:
return jsonify({"error": "Academic period not found"}), 404
if period.is_archived:
return jsonify({"error": "Cannot import holidays into an archived academic period"}), 409
inserted = 0
updated = 0
merged_overlaps = 0
skipped_duplicates = 0
conflicts = []
def build_exact_key(name, start_date, end_date, region):
normalized_name = _normalize_optional_text(name)
normalized_region = _normalize_optional_text(region)
return (
(normalized_name or "").casefold(),
start_date,
end_date,
(normalized_region or "").casefold(),
)
seen_in_file = set()
# First, try headered CSV via DictReader
dict_reader = csv.DictReader(io.StringIO(
@@ -90,34 +295,67 @@ def upload_holidays():
has_required_headers = {"name", "start_date",
"end_date"}.issubset(set(fieldnames_lower))
def upsert(name: str, start_date, end_date, region=None):
nonlocal inserted, updated
def upsert(name: str, start_date, end_date, region=None, source_label="Ferienblock"):
nonlocal inserted, updated, merged_overlaps, skipped_duplicates
if not name or not start_date or not end_date:
return
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()
_validate_holiday_dates_within_period(period, start_date, end_date, source_label)
normalized_name = _normalize_optional_text(name)
normalized_region = _normalize_optional_text(region)
key = build_exact_key(normalized_name, start_date, end_date, normalized_region)
if key in seen_in_file:
skipped_duplicates += 1
return
seen_in_file.add(key)
duplicate = _find_duplicate_holiday(
session,
academic_period_id,
normalized_name,
start_date,
end_date,
normalized_region,
)
if existing:
existing.region = region
existing.source_file_name = file.filename
if duplicate:
duplicate.source_file_name = file.filename
updated += 1
else:
session.add(SchoolHoliday(
name=name,
start_date=start_date,
end_date=end_date,
region=region,
return
overlaps = _find_overlapping_holidays(
session,
academic_period_id,
start_date,
end_date,
)
same_identity, conflicting = _split_overlap_candidates(overlaps, normalized_name, normalized_region)
if conflicting:
conflicts.append(_format_overlap_conflict(source_label, conflicting))
return
if same_identity:
keeper = same_identity[0]
_merge_holiday_group(
session,
keeper,
same_identity[1:],
normalized_name,
start_date,
end_date,
normalized_region,
source_file_name=file.filename,
))
inserted += 1
)
merged_overlaps += 1
return
session.add(SchoolHoliday(
academic_period_id=academic_period_id,
name=normalized_name,
start_date=start_date,
end_date=end_date,
region=normalized_region,
source_file_name=file.filename,
))
inserted += 1
if has_required_headers:
for row in dict_reader:
@@ -131,12 +369,12 @@ def upload_holidays():
continue
region = (norm.get("region")
or None) if "region" in norm else None
upsert(name, start_date, end_date, region)
upsert(name, start_date, end_date, region, f"Zeile {dict_reader.line_num}")
else:
# Fallback: headerless rows -> use columns [1]=name, [2]=start, [3]=end
reader = csv.reader(io.StringIO(
content), dialect=dialect) if dialect else csv.reader(io.StringIO(content))
for row in reader:
for row_index, row in enumerate(reader, start=1):
if not row:
continue
# tolerate varying column counts (4 or 5); ignore first and optional last
@@ -152,10 +390,214 @@ def upload_holidays():
end_date = parse_date(end_raw)
except ValueError:
continue
upsert(name, start_date, end_date, None)
upsert(name, start_date, end_date, None, f"Zeile {row_index}")
session.commit()
session.close()
return jsonify({"success": True, "inserted": inserted, "updated": updated})
except Exception as e:
return jsonify({
"success": True,
"inserted": inserted,
"updated": updated,
"merged_overlaps": merged_overlaps,
"skipped_duplicates": skipped_duplicates,
"conflicts": conflicts,
"academic_period_id": academic_period_id,
})
except ValueError as e:
session.rollback()
return jsonify({"error": str(e)}), 400
except Exception as e:
session.rollback()
return jsonify({"error": str(e)}), 400
finally:
session.close()
@holidays_bp.route("", methods=["POST"])
@admin_or_higher
def create_holiday():
data = request.json or {}
name = _normalize_optional_text(data.get("name")) or ""
start_date_str = (data.get("start_date") or "").strip()
end_date_str = (data.get("end_date") or "").strip()
region = _normalize_optional_text(data.get("region"))
if not name or not start_date_str or not end_date_str:
return jsonify({"error": "name, start_date und end_date sind erforderlich"}), 400
try:
start_date_val = date.fromisoformat(start_date_str)
end_date_val = date.fromisoformat(end_date_str)
except ValueError:
return jsonify({"error": "Ung\u00fcltiges Datumsformat. Erwartet: YYYY-MM-DD"}), 400
if end_date_val < start_date_val:
return jsonify({"error": "Enddatum muss nach oder gleich Startdatum sein"}), 400
academic_period_id = _parse_academic_period_id(data.get("academic_period_id"))
session = Session()
try:
period = None
if academic_period_id is not None:
period = session.query(AcademicPeriod).get(academic_period_id)
if not period:
return jsonify({"error": "Akademische Periode nicht gefunden"}), 404
if period.is_archived:
return jsonify({"error": "Archivierte Perioden k\u00f6nnen nicht bearbeitet werden"}), 409
_validate_holiday_dates_within_period(period, start_date_val, end_date_val)
duplicate = _find_duplicate_holiday(
session,
academic_period_id,
name,
start_date_val,
end_date_val,
region,
)
if duplicate:
return jsonify({"error": "Ein Ferienblock mit diesem Namen und Zeitraum existiert bereits in dieser Periode"}), 409
overlaps = _find_overlapping_holidays(session, academic_period_id, start_date_val, end_date_val)
same_identity, conflicting = _split_overlap_candidates(overlaps, name, region)
if conflicting:
return jsonify({"error": _format_overlap_conflict("Der Ferienblock", conflicting)}), 409
merged = False
if same_identity:
holiday = _merge_holiday_group(
session,
same_identity[0],
same_identity[1:],
name,
start_date_val,
end_date_val,
region,
source_file_name="manual",
)
merged = True
else:
holiday = SchoolHoliday(
academic_period_id=academic_period_id,
name=name,
start_date=start_date_val,
end_date=end_date_val,
region=region,
source_file_name="manual",
)
session.add(holiday)
session.flush()
regenerated = _regenerate_for_period(session, academic_period_id)
session.commit()
return jsonify({"success": True, "holiday": holiday.to_dict(), "regenerated_events": regenerated, "merged": merged}), 201
except IntegrityError:
session.rollback()
return jsonify({"error": "Ein Ferienblock mit diesem Namen und Zeitraum existiert bereits in dieser Periode"}), 409
except ValueError as e:
session.rollback()
return jsonify({"error": str(e)}), 400
except Exception as e:
session.rollback()
return jsonify({"error": str(e)}), 400
finally:
session.close()
@holidays_bp.route("/<int:holiday_id>", methods=["PUT"])
@admin_or_higher
def update_holiday(holiday_id):
data = request.json or {}
session = Session()
try:
holiday = session.query(SchoolHoliday).get(holiday_id)
if not holiday:
return jsonify({"error": "Ferienblock nicht gefunden"}), 404
period = None
if holiday.academic_period_id is not None:
period = session.query(AcademicPeriod).get(holiday.academic_period_id)
if period and period.is_archived:
return jsonify({"error": "Archivierte Perioden k\u00f6nnen nicht bearbeitet werden"}), 409
if "name" in data:
holiday.name = _normalize_optional_text(data["name"]) or ""
if "start_date" in data:
try:
holiday.start_date = date.fromisoformat((data["start_date"] or "").strip())
except ValueError:
return jsonify({"error": "Ung\u00fcltiges Startdatum. Erwartet: YYYY-MM-DD"}), 400
if "end_date" in data:
try:
holiday.end_date = date.fromisoformat((data["end_date"] or "").strip())
except ValueError:
return jsonify({"error": "Ung\u00fcltiges Enddatum. Erwartet: YYYY-MM-DD"}), 400
if "region" in data:
holiday.region = _normalize_optional_text(data["region"])
if not holiday.name:
return jsonify({"error": "Name darf nicht leer sein"}), 400
if holiday.end_date < holiday.start_date:
return jsonify({"error": "Enddatum muss nach oder gleich Startdatum sein"}), 400
_validate_holiday_dates_within_period(period, holiday.start_date, holiday.end_date)
duplicate = _find_duplicate_holiday(
session,
holiday.academic_period_id,
holiday.name,
holiday.start_date,
holiday.end_date,
holiday.region,
exclude_id=holiday.id,
)
if duplicate:
return jsonify({"error": "Ein Ferienblock mit diesem Namen und Zeitraum existiert bereits in dieser Periode"}), 409
overlaps = _find_overlapping_holidays(
session,
holiday.academic_period_id,
holiday.start_date,
holiday.end_date,
exclude_id=holiday.id,
)
same_identity, conflicting = _split_overlap_candidates(overlaps, holiday.name, holiday.region)
if conflicting:
return jsonify({"error": _format_overlap_conflict("Der Ferienblock", conflicting)}), 409
merged = False
if same_identity:
_merge_holiday_group(
session,
holiday,
same_identity,
holiday.name,
holiday.start_date,
holiday.end_date,
holiday.region,
source_file_name="manual",
)
merged = True
session.flush()
academic_period_id = holiday.academic_period_id
regenerated = _regenerate_for_period(session, academic_period_id)
session.commit()
return jsonify({"success": True, "holiday": holiday.to_dict(), "regenerated_events": regenerated, "merged": merged})
except IntegrityError:
session.rollback()
return jsonify({"error": "Ein Ferienblock mit diesem Namen und Zeitraum existiert bereits in dieser Periode"}), 409
except Exception as e:
session.rollback()
return jsonify({"error": str(e)}), 400
finally:
session.close()
@holidays_bp.route("/<int:holiday_id>", methods=["DELETE"])
@admin_or_higher
def delete_holiday(holiday_id):
session = Session()
try:
holiday = session.query(SchoolHoliday).get(holiday_id)
if not holiday:
return jsonify({"error": "Ferienblock nicht gefunden"}), 404
if holiday.academic_period_id is not None:
period = session.query(AcademicPeriod).get(holiday.academic_period_id)
if period and period.is_archived:
return jsonify({"error": "Archivierte Perioden k\u00f6nnen nicht bearbeitet werden"}), 409
academic_period_id = holiday.academic_period_id
session.delete(holiday)
session.flush()
regenerated = _regenerate_for_period(session, academic_period_id)
session.commit()
return jsonify({"success": True, "regenerated_events": regenerated})
except Exception as e:
session.rollback()
return jsonify({"error": str(e)}), 400
finally:
session.close()