Files
infoscreen/server/routes/academic_periods.py
Olaf b5f5f30005 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
2026-03-31 12:25:55 +00:00

506 lines
18 KiB
Python

"""
Academic periods management routes.
Endpoints for full CRUD lifecycle including archive, restore, and hard delete.
All write operations require admin+ role.
"""
from flask import Blueprint, jsonify, request, session
from server.permissions import admin_or_higher
from server.database import Session
from server.serializers import dict_to_camel_case
from models.models import AcademicPeriod, Event
from datetime import datetime, timezone
from sqlalchemy import and_
from dateutil.rrule import rrulestr
from dateutil.tz import UTC
import sys
sys.path.append('/workspace')
academic_periods_bp = Blueprint(
'academic_periods', __name__, url_prefix='/api/academic_periods')
# ============================================================================
# GET ENDPOINTS
# ============================================================================
@academic_periods_bp.route('', methods=['GET'])
def list_academic_periods():
"""List academic periods with optional archived visibility filters, ordered by start_date."""
db_session = Session()
try:
include_archived = request.args.get('includeArchived', '0') == '1'
archived_only = request.args.get('archivedOnly', '0') == '1'
query = db_session.query(AcademicPeriod)
if archived_only:
query = query.filter(AcademicPeriod.is_archived == True)
elif not include_archived:
query = query.filter(AcademicPeriod.is_archived == False)
periods = query.order_by(AcademicPeriod.start_date.asc()).all()
result = [dict_to_camel_case(p.to_dict()) for p in periods]
return jsonify({'periods': result}), 200
finally:
db_session.close()
@academic_periods_bp.route('/<int:period_id>', methods=['GET'])
def get_academic_period(period_id):
"""Get a single academic period by ID (including archived)."""
db_session = Session()
try:
period = db_session.query(AcademicPeriod).get(period_id)
if not period:
return jsonify({'error': 'AcademicPeriod not found'}), 404
return jsonify({'period': dict_to_camel_case(period.to_dict())}), 200
finally:
db_session.close()
@academic_periods_bp.route('/active', methods=['GET'])
def get_active_academic_period():
"""Get the currently active academic period."""
db_session = Session()
try:
period = db_session.query(AcademicPeriod).filter(
AcademicPeriod.is_active == True
).first()
if not period:
return jsonify({'period': None}), 200
return jsonify({'period': dict_to_camel_case(period.to_dict())}), 200
finally:
db_session.close()
@academic_periods_bp.route('/for_date', methods=['GET'])
def get_period_for_date():
"""
Returns the non-archived academic period that covers the provided date (YYYY-MM-DD).
If multiple match, prefer the one with the latest start_date.
"""
date_str = request.args.get('date')
if not date_str:
return jsonify({'error': 'Missing required query param: date (YYYY-MM-DD)'}), 400
try:
target = datetime.strptime(date_str, '%Y-%m-%d').date()
except ValueError:
return jsonify({'error': 'Invalid date format. Expected YYYY-MM-DD'}), 400
db_session = Session()
try:
period = (
db_session.query(AcademicPeriod)
.filter(
AcademicPeriod.start_date <= target,
AcademicPeriod.end_date >= target,
AcademicPeriod.is_archived == False
)
.order_by(AcademicPeriod.start_date.desc())
.first()
)
return jsonify({'period': dict_to_camel_case(period.to_dict()) if period else None}), 200
finally:
db_session.close()
@academic_periods_bp.route('/<int:period_id>/usage', methods=['GET'])
def get_period_usage(period_id):
"""
Check what events and media are linked to this period.
Used for pre-flight checks before delete/archive.
Returns:
{
"linked_events": count,
"linked_media": count,
"has_active_recurrence": boolean,
"blockers": ["list of reasons why delete/archive would fail"]
}
"""
db_session = Session()
try:
period = db_session.query(AcademicPeriod).get(period_id)
if not period:
return jsonify({'error': 'AcademicPeriod not found'}), 404
# Count linked events
linked_events = db_session.query(Event).filter(
Event.academic_period_id == period_id
).count()
# Check for active recurrence (events with recurrence_rule that have future occurrences)
has_active_recurrence = False
blockers = []
now = datetime.now(timezone.utc)
recurring_events = db_session.query(Event).filter(
Event.academic_period_id == period_id,
Event.recurrence_rule != None
).all()
for evt in recurring_events:
try:
rrule_obj = rrulestr(evt.recurrence_rule, dtstart=evt.start)
# Check if there are any future occurrences
next_occurrence = rrule_obj.after(now, inc=True)
if next_occurrence:
has_active_recurrence = True
blockers.append(f"Recurring event '{evt.title}' has active occurrences")
break
except Exception:
pass
# If period is active, cannot archive/delete
if period.is_active:
blockers.append("Cannot archive or delete an active period")
return jsonify({
'usage': {
'linked_events': linked_events,
'has_active_recurrence': has_active_recurrence,
'blockers': blockers
}
}), 200
finally:
db_session.close()
# ============================================================================
# CREATE ENDPOINT
# ============================================================================
@academic_periods_bp.route('', methods=['POST'])
@admin_or_higher
def create_academic_period():
"""
Create a new academic period.
Request body:
{
"name": "Schuljahr 2026/27",
"displayName": "SJ 26/27",
"startDate": "2026-09-01",
"endDate": "2027-08-31",
"periodType": "schuljahr"
}
"""
data = request.get_json(silent=True) or {}
# Validate required fields
name = data.get('name', '').strip()
if not name:
return jsonify({'error': 'Name is required and cannot be empty'}), 400
start_date_str = data.get('startDate')
end_date_str = data.get('endDate')
period_type = data.get('periodType', 'schuljahr')
display_name = data.get('displayName', '').strip() or None
# Parse dates
try:
start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date()
end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date()
except (ValueError, TypeError):
return jsonify({'error': 'Invalid date format. Expected YYYY-MM-DD'}), 400
# Validate date range
if start_date > end_date:
return jsonify({'error': 'Start date must be less than or equal to end date'}), 400
# Validate period type
valid_types = ['schuljahr', 'semester', 'trimester']
if period_type not in valid_types:
return jsonify({'error': f'Invalid periodType. Must be one of: {", ".join(valid_types)}'}), 400
db_session = Session()
try:
# Check name uniqueness among non-archived periods
existing = db_session.query(AcademicPeriod).filter(
AcademicPeriod.name == name,
AcademicPeriod.is_archived == False
).first()
if existing:
return jsonify({'error': 'A non-archived period with this name already exists'}), 409
# Check for overlaps within same period type
overlapping = db_session.query(AcademicPeriod).filter(
AcademicPeriod.period_type == period_type,
AcademicPeriod.is_archived == False,
AcademicPeriod.start_date <= end_date,
AcademicPeriod.end_date >= start_date
).first()
if overlapping:
return jsonify({'error': f'Overlapping {period_type} period already exists'}), 409
# Create period
period = AcademicPeriod(
name=name,
display_name=display_name,
start_date=start_date,
end_date=end_date,
period_type=period_type,
is_active=False,
is_archived=False
)
db_session.add(period)
db_session.commit()
db_session.refresh(period)
return jsonify({'period': dict_to_camel_case(period.to_dict())}), 201
finally:
db_session.close()
# ============================================================================
# UPDATE ENDPOINT
# ============================================================================
@academic_periods_bp.route('/<int:period_id>', methods=['PUT'])
@admin_or_higher
def update_academic_period(period_id):
"""
Update an academic period (cannot be archived).
Request body (all fields optional):
{
"name": "...",
"displayName": "...",
"startDate": "YYYY-MM-DD",
"endDate": "YYYY-MM-DD",
"periodType": "schuljahr|semester|trimester"
}
"""
db_session = Session()
try:
period = db_session.query(AcademicPeriod).get(period_id)
if not period:
return jsonify({'error': 'AcademicPeriod not found'}), 404
if period.is_archived:
return jsonify({'error': 'Cannot update an archived period'}), 409
data = request.get_json(silent=True) or {}
# Update fields if provided
if 'name' in data:
name = data['name'].strip()
if not name:
return jsonify({'error': 'Name cannot be empty'}), 400
# Check uniqueness among non-archived (excluding self)
existing = db_session.query(AcademicPeriod).filter(
AcademicPeriod.name == name,
AcademicPeriod.is_archived == False,
AcademicPeriod.id != period_id
).first()
if existing:
return jsonify({'error': 'A non-archived period with this name already exists'}), 409
period.name = name
if 'displayName' in data:
period.display_name = data['displayName'].strip() or None
if 'periodType' in data:
period_type = data['periodType']
valid_types = ['schuljahr', 'semester', 'trimester']
if period_type not in valid_types:
return jsonify({'error': f'Invalid periodType. Must be one of: {", ".join(valid_types)}'}), 400
period.period_type = period_type
# Handle date updates with overlap checking
if 'startDate' in data or 'endDate' in data:
start_date = period.start_date
end_date = period.end_date
if 'startDate' in data:
try:
start_date = datetime.strptime(data['startDate'], '%Y-%m-%d').date()
except (ValueError, TypeError):
return jsonify({'error': 'Invalid startDate format. Expected YYYY-MM-DD'}), 400
if 'endDate' in data:
try:
end_date = datetime.strptime(data['endDate'], '%Y-%m-%d').date()
except (ValueError, TypeError):
return jsonify({'error': 'Invalid endDate format. Expected YYYY-MM-DD'}), 400
if start_date > end_date:
return jsonify({'error': 'Start date must be less than or equal to end date'}), 400
# Check for overlaps within same period type (excluding self)
overlapping = db_session.query(AcademicPeriod).filter(
AcademicPeriod.period_type == period.period_type,
AcademicPeriod.is_archived == False,
AcademicPeriod.id != period_id,
AcademicPeriod.start_date <= end_date,
AcademicPeriod.end_date >= start_date
).first()
if overlapping:
return jsonify({'error': f'Overlapping {period.period_type.value} period already exists'}), 409
period.start_date = start_date
period.end_date = end_date
db_session.commit()
db_session.refresh(period)
return jsonify({'period': dict_to_camel_case(period.to_dict())}), 200
finally:
db_session.close()
# ============================================================================
# ACTIVATE ENDPOINT
# ============================================================================
@academic_periods_bp.route('/<int:period_id>/activate', methods=['POST'])
@admin_or_higher
def activate_academic_period(period_id):
"""
Activate an academic period (deactivates all others).
Cannot activate an archived period.
"""
db_session = Session()
try:
period = db_session.query(AcademicPeriod).get(period_id)
if not period:
return jsonify({'error': 'AcademicPeriod not found'}), 404
if period.is_archived:
return jsonify({'error': 'Cannot activate an archived period'}), 409
# Deactivate all, then activate target
db_session.query(AcademicPeriod).update({AcademicPeriod.is_active: False})
period.is_active = True
db_session.commit()
db_session.refresh(period)
return jsonify({'period': dict_to_camel_case(period.to_dict())}), 200
finally:
db_session.close()
# ============================================================================
# ARCHIVE/RESTORE ENDPOINTS
# ============================================================================
@academic_periods_bp.route('/<int:period_id>/archive', methods=['POST'])
@admin_or_higher
def archive_academic_period(period_id):
"""
Archive an academic period (soft delete).
Cannot archive an active period or one with active recurring events.
"""
db_session = Session()
try:
period = db_session.query(AcademicPeriod).get(period_id)
if not period:
return jsonify({'error': 'AcademicPeriod not found'}), 404
if period.is_archived:
return jsonify({'error': 'Period already archived'}), 409
if period.is_active:
return jsonify({'error': 'Cannot archive an active period'}), 409
# Check for recurrence spillover
now = datetime.now(timezone.utc)
recurring_events = db_session.query(Event).filter(
Event.academic_period_id == period_id,
Event.recurrence_rule != None
).all()
for evt in recurring_events:
try:
rrule_obj = rrulestr(evt.recurrence_rule, dtstart=evt.start)
next_occurrence = rrule_obj.after(now, inc=True)
if next_occurrence:
return jsonify({'error': f'Cannot archive: recurring event "{evt.title}" has active occurrences'}), 409
except Exception:
pass
# Archive
user_id = session.get('user_id')
period.is_archived = True
period.archived_at = datetime.now(timezone.utc)
period.archived_by = user_id
db_session.commit()
db_session.refresh(period)
return jsonify({'period': dict_to_camel_case(period.to_dict())}), 200
finally:
db_session.close()
@academic_periods_bp.route('/<int:period_id>/restore', methods=['POST'])
@admin_or_higher
def restore_academic_period(period_id):
"""
Restore an archived academic period (returns to inactive state).
"""
db_session = Session()
try:
period = db_session.query(AcademicPeriod).get(period_id)
if not period:
return jsonify({'error': 'AcademicPeriod not found'}), 404
if not period.is_archived:
return jsonify({'error': 'Period is not archived'}), 409
# Restore
period.is_archived = False
period.archived_at = None
period.archived_by = None
db_session.commit()
db_session.refresh(period)
return jsonify({'period': dict_to_camel_case(period.to_dict())}), 200
finally:
db_session.close()
# ============================================================================
# DELETE ENDPOINT
# ============================================================================
@academic_periods_bp.route('/<int:period_id>', methods=['DELETE'])
@admin_or_higher
def delete_academic_period(period_id):
"""
Hard delete an archived, inactive academic period.
Blocked if linked events exist, linked media exist, or recurrence spillover detected.
"""
db_session = Session()
try:
period = db_session.query(AcademicPeriod).get(period_id)
if not period:
return jsonify({'error': 'AcademicPeriod not found'}), 404
if not period.is_archived:
return jsonify({'error': 'Cannot hard-delete a non-archived period'}), 409
if period.is_active:
return jsonify({'error': 'Cannot hard-delete an active period'}), 409
# Check for linked events
linked_events = db_session.query(Event).filter(
Event.academic_period_id == period_id
).count()
if linked_events > 0:
return jsonify({'error': f'Cannot delete: {linked_events} event(s) linked to this period'}), 409
# Delete
db_session.delete(period)
db_session.commit()
return jsonify({'message': 'Period deleted successfully'}), 200
finally:
db_session.close()