- 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
506 lines
18 KiB
Python
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()
|