""" 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('/', 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('//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('/', 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('//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('//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('//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('/', 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()