""" Classroom API - Teacher Context Endpoints (v1 API). Endpoints fuer Teacher Context, Events, Routines und Antizipations-Engine. """ from typing import Dict, List, Optional, Any from datetime import datetime import logging from fastapi import APIRouter, HTTPException, Query, Depends from pydantic import BaseModel, Field from sqlalchemy.orm import Session as DBSession from .shared import init_db_if_needed, DB_ENABLED, logger try: from classroom_engine.database import get_db, SessionLocal from classroom_engine.repository import ( TeacherContextRepository, SchoolyearEventRepository, RecurringRoutineRepository ) from classroom_engine.context_models import ( MacroPhaseEnum, EventTypeEnum, EventStatusEnum, RoutineTypeEnum, RecurrencePatternEnum, FEDERAL_STATES, SCHOOL_TYPES ) from classroom_engine.antizipation import SuggestionGenerator except ImportError: FEDERAL_STATES = {} SCHOOL_TYPES = {} router = APIRouter(prefix="/v1", tags=["Teacher Context"]) # === Pydantic Models === class SchoolInfo(BaseModel): federal_state: str federal_state_name: str school_type: str school_type_name: str class SchoolYearInfo(BaseModel): id: str start: Optional[str] current_week: int class MacroPhaseInfo(BaseModel): id: str label: str confidence: float class CoreCounts(BaseModel): classes: int = 0 exams_scheduled: int = 0 corrections_pending: int = 0 class ContextFlags(BaseModel): onboarding_completed: bool = False has_classes: bool = False has_schedule: bool = False is_exam_period: bool = False is_before_holidays: bool = False class TeacherContextResponse(BaseModel): schema_version: str = "1.0" teacher_id: str school: SchoolInfo school_year: SchoolYearInfo macro_phase: MacroPhaseInfo core_counts: CoreCounts flags: ContextFlags class UpdateContextRequest(BaseModel): federal_state: Optional[str] = None school_type: Optional[str] = None schoolyear: Optional[str] = None schoolyear_start: Optional[str] = None macro_phase: Optional[str] = None current_week: Optional[int] = None class CreateEventRequest(BaseModel): title: str = Field(..., max_length=300) event_type: str = "other" start_date: str end_date: Optional[str] = None class_id: Optional[str] = None subject: Optional[str] = None description: str = "" needs_preparation: bool = False reminder_days_before: int = 3 class EventResponse(BaseModel): id: str teacher_id: str event_type: str title: str description: str start_date: str end_date: Optional[str] class_id: Optional[str] subject: Optional[str] status: str needs_preparation: bool preparation_done: bool reminder_days_before: int class CreateRoutineRequest(BaseModel): title: str routine_type: str = "other" recurrence_pattern: str = "weekly" day_of_week: Optional[int] = None day_of_month: Optional[int] = None time_of_day: Optional[str] = None duration_minutes: int = 60 description: str = "" class RoutineResponse(BaseModel): id: str teacher_id: str routine_type: str title: str description: str recurrence_pattern: str day_of_week: Optional[int] day_of_month: Optional[int] time_of_day: Optional[str] duration_minutes: int is_active: bool # === Helper Functions === def get_macro_phase_label(phase) -> str: """Gibt den Anzeigenamen einer Makro-Phase zurueck.""" labels = { "onboarding": "Einrichtung", "schuljahresstart": "Schuljahresstart", "unterrichtsaufbau": "Unterrichtsaufbau", "leistungsphase_1": "Leistungsphase 1", "halbjahresabschluss": "Halbjahresabschluss", "leistungsphase_2": "Leistungsphase 2", "jahresabschluss": "Jahresabschluss", } phase_value = phase.value if hasattr(phase, 'value') else str(phase) return labels.get(phase_value, phase_value) def get_default_context_response(teacher_id: str) -> TeacherContextResponse: """Gibt eine Default-Context-Response zurueck.""" return TeacherContextResponse( teacher_id=teacher_id, school=SchoolInfo( federal_state="BY", federal_state_name="Bayern", school_type="gymnasium", school_type_name="Gymnasium", ), school_year=SchoolYearInfo(id="2024-2025", start=None, current_week=1), macro_phase=MacroPhaseInfo(id="onboarding", label="Einrichtung", confidence=1.0), core_counts=CoreCounts(), flags=ContextFlags(), ) # === Context Endpoints === @router.get("/context", response_model=TeacherContextResponse) async def get_teacher_context(teacher_id: str = Query(...)): """Liefert den aktuellen Makro-Kontext eines Lehrers.""" if not DB_ENABLED: return get_default_context_response(teacher_id) try: db = SessionLocal() repo = TeacherContextRepository(db) context = repo.get_or_create(teacher_id) event_repo = SchoolyearEventRepository(db) upcoming_exams = event_repo.get_upcoming(teacher_id, days=30) exams_count = len([e for e in upcoming_exams if e.event_type.value == "exam"]) result = TeacherContextResponse( teacher_id=teacher_id, school=SchoolInfo( federal_state=context.federal_state or "BY", federal_state_name=FEDERAL_STATES.get(context.federal_state, ""), school_type=context.school_type or "gymnasium", school_type_name=SCHOOL_TYPES.get(context.school_type, ""), ), school_year=SchoolYearInfo( id=context.schoolyear or "2024-2025", start=context.schoolyear_start.isoformat() if context.schoolyear_start else None, current_week=context.current_week or 1, ), macro_phase=MacroPhaseInfo( id=context.macro_phase.value, label=get_macro_phase_label(context.macro_phase), confidence=1.0, ), core_counts=CoreCounts( classes=1 if context.has_classes else 0, exams_scheduled=exams_count, ), flags=ContextFlags( onboarding_completed=context.onboarding_completed, has_classes=context.has_classes, has_schedule=context.has_schedule, is_exam_period=context.is_exam_period, is_before_holidays=context.is_before_holidays, ), ) db.close() return result except Exception as e: logger.error(f"Failed to get teacher context: {e}") raise HTTPException(status_code=500, detail=f"Fehler beim Laden des Kontexts: {e}") @router.put("/context", response_model=TeacherContextResponse) async def update_teacher_context(teacher_id: str, request: UpdateContextRequest): """Aktualisiert den Kontext eines Lehrers.""" if not DB_ENABLED: raise HTTPException(status_code=503, detail="Datenbank nicht verfuegbar") try: db = SessionLocal() repo = TeacherContextRepository(db) if request.federal_state and request.federal_state not in FEDERAL_STATES: raise HTTPException(status_code=400, detail=f"Ungueltiges Bundesland: {request.federal_state}") if request.school_type and request.school_type not in SCHOOL_TYPES: raise HTTPException(status_code=400, detail=f"Ungueltige Schulart: {request.school_type}") schoolyear_start = None if request.schoolyear_start: schoolyear_start = datetime.fromisoformat(request.schoolyear_start.replace('Z', '+00:00')) repo.update_context( teacher_id=teacher_id, federal_state=request.federal_state, school_type=request.school_type, schoolyear=request.schoolyear, schoolyear_start=schoolyear_start, macro_phase=request.macro_phase, current_week=request.current_week, ) db.close() return await get_teacher_context(teacher_id) except HTTPException: raise except Exception as e: logger.error(f"Failed to update teacher context: {e}") raise HTTPException(status_code=500, detail=f"Fehler beim Aktualisieren: {e}") @router.post("/context/complete-onboarding") async def complete_onboarding(teacher_id: str = Query(...)): """Markiert das Onboarding als abgeschlossen.""" if not DB_ENABLED: return {"success": True, "macro_phase": "schuljahresstart", "note": "DB not available"} try: db = SessionLocal() repo = TeacherContextRepository(db) context = repo.complete_onboarding(teacher_id) db.close() return {"success": True, "macro_phase": context.macro_phase.value, "teacher_id": teacher_id} except Exception as e: logger.error(f"Failed to complete onboarding: {e}") raise HTTPException(status_code=500, detail=f"Fehler: {e}") @router.post("/context/reset-onboarding") async def reset_onboarding(teacher_id: str = Query(...)): """Setzt das Onboarding zurueck (fuer Tests).""" if not DB_ENABLED: return {"success": True, "macro_phase": "onboarding", "note": "DB not available"} try: db = SessionLocal() repo = TeacherContextRepository(db) context = repo.get_or_create(teacher_id) context.onboarding_completed = False context.macro_phase = MacroPhaseEnum.ONBOARDING db.commit() db.close() return {"success": True, "macro_phase": "onboarding", "teacher_id": teacher_id} except Exception as e: logger.error(f"Failed to reset onboarding: {e}") raise HTTPException(status_code=500, detail=f"Fehler: {e}") # === Events Endpoints === @router.get("/events") async def get_events( teacher_id: str = Query(...), status: Optional[str] = None, event_type: Optional[str] = None, limit: int = 50 ): """Holt Events eines Lehrers.""" if not DB_ENABLED: return {"events": [], "count": 0} try: db = SessionLocal() repo = SchoolyearEventRepository(db) events = repo.get_by_teacher(teacher_id, status=status, event_type=event_type, limit=limit) result = {"events": [repo.to_dict(e) for e in events], "count": len(events)} db.close() return result except Exception as e: logger.error(f"Failed to get events: {e}") raise HTTPException(status_code=500, detail=f"Fehler: {e}") @router.get("/events/upcoming") async def get_upcoming_events(teacher_id: str = Query(...), days: int = 30, limit: int = 10): """Holt anstehende Events der naechsten X Tage.""" if not DB_ENABLED: return {"events": [], "count": 0} try: db = SessionLocal() repo = SchoolyearEventRepository(db) events = repo.get_upcoming(teacher_id, days=days, limit=limit) result = {"events": [repo.to_dict(e) for e in events], "count": len(events)} db.close() return result except Exception as e: logger.error(f"Failed to get upcoming events: {e}") raise HTTPException(status_code=500, detail=f"Fehler: {e}") @router.post("/events", response_model=EventResponse) async def create_event(teacher_id: str, request: CreateEventRequest): """Erstellt ein neues Schuljahr-Event.""" if not DB_ENABLED: raise HTTPException(status_code=503, detail="Datenbank nicht verfuegbar") try: db = SessionLocal() repo = SchoolyearEventRepository(db) start_date = datetime.fromisoformat(request.start_date.replace('Z', '+00:00')) end_date = datetime.fromisoformat(request.end_date.replace('Z', '+00:00')) if request.end_date else None event = repo.create( teacher_id=teacher_id, title=request.title, event_type=request.event_type, start_date=start_date, end_date=end_date, class_id=request.class_id, subject=request.subject, description=request.description, needs_preparation=request.needs_preparation, reminder_days_before=request.reminder_days_before, ) result = EventResponse( id=event.id, teacher_id=event.teacher_id, event_type=event.event_type.value, title=event.title, description=event.description, start_date=event.start_date.isoformat(), end_date=event.end_date.isoformat() if event.end_date else None, class_id=event.class_id, subject=event.subject, status=event.status.value, needs_preparation=event.needs_preparation, preparation_done=event.preparation_done, reminder_days_before=event.reminder_days_before, ) db.close() return result except Exception as e: logger.error(f"Failed to create event: {e}") raise HTTPException(status_code=500, detail=f"Fehler: {e}") @router.delete("/events/{event_id}") async def delete_event(event_id: str): """Loescht ein Event.""" if not DB_ENABLED: raise HTTPException(status_code=503, detail="Datenbank nicht verfuegbar") try: db = SessionLocal() repo = SchoolyearEventRepository(db) if repo.delete(event_id): db.close() return {"success": True, "deleted_id": event_id} db.close() raise HTTPException(status_code=404, detail="Event nicht gefunden") except HTTPException: raise except Exception as e: logger.error(f"Failed to delete event: {e}") raise HTTPException(status_code=500, detail=f"Fehler: {e}") # === Routines Endpoints === @router.get("/routines") async def get_routines( teacher_id: str = Query(...), is_active: bool = True, routine_type: Optional[str] = None ): """Holt Routinen eines Lehrers.""" if not DB_ENABLED: return {"routines": [], "count": 0} try: db = SessionLocal() repo = RecurringRoutineRepository(db) routines = repo.get_by_teacher(teacher_id, is_active=is_active, routine_type=routine_type) result = {"routines": [repo.to_dict(r) for r in routines], "count": len(routines)} db.close() return result except Exception as e: logger.error(f"Failed to get routines: {e}") raise HTTPException(status_code=500, detail=f"Fehler: {e}") @router.get("/routines/today") async def get_today_routines(teacher_id: str = Query(...)): """Holt Routinen die heute stattfinden.""" if not DB_ENABLED: return {"routines": [], "count": 0} try: db = SessionLocal() repo = RecurringRoutineRepository(db) routines = repo.get_today(teacher_id) result = {"routines": [repo.to_dict(r) for r in routines], "count": len(routines)} db.close() return result except Exception as e: logger.error(f"Failed to get today's routines: {e}") raise HTTPException(status_code=500, detail=f"Fehler: {e}") @router.post("/routines", response_model=RoutineResponse) async def create_routine(teacher_id: str, request: CreateRoutineRequest): """Erstellt eine neue wiederkehrende Routine.""" if not DB_ENABLED: raise HTTPException(status_code=503, detail="Datenbank nicht verfuegbar") try: db = SessionLocal() repo = RecurringRoutineRepository(db) routine = repo.create( teacher_id=teacher_id, title=request.title, routine_type=request.routine_type, recurrence_pattern=request.recurrence_pattern, day_of_week=request.day_of_week, day_of_month=request.day_of_month, time_of_day=request.time_of_day, duration_minutes=request.duration_minutes, description=request.description, ) result = RoutineResponse( id=routine.id, teacher_id=routine.teacher_id, routine_type=routine.routine_type.value, title=routine.title, description=routine.description, recurrence_pattern=routine.recurrence_pattern.value, day_of_week=routine.day_of_week, day_of_month=routine.day_of_month, time_of_day=routine.time_of_day.isoformat() if routine.time_of_day else None, duration_minutes=routine.duration_minutes, is_active=routine.is_active, ) db.close() return result except Exception as e: logger.error(f"Failed to create routine: {e}") raise HTTPException(status_code=500, detail=f"Fehler: {e}") @router.delete("/routines/{routine_id}") async def delete_routine(routine_id: str): """Loescht eine Routine.""" if not DB_ENABLED: raise HTTPException(status_code=503, detail="Datenbank nicht verfuegbar") try: db = SessionLocal() repo = RecurringRoutineRepository(db) if repo.delete(routine_id): db.close() return {"success": True, "deleted_id": routine_id} db.close() raise HTTPException(status_code=404, detail="Routine nicht gefunden") except HTTPException: raise except Exception as e: logger.error(f"Failed to delete routine: {e}") raise HTTPException(status_code=500, detail=f"Fehler: {e}") # === Static Data Endpoints === @router.get("/federal-states") async def get_federal_states_list(): """Gibt alle Bundeslaender zurueck.""" return {"federal_states": [{"id": k, "name": v} for k, v in FEDERAL_STATES.items()]} @router.get("/school-types") async def get_school_types_list(): """Gibt alle Schularten zurueck.""" return {"school_types": [{"id": k, "name": v} for k, v in SCHOOL_TYPES.items()]} @router.get("/macro-phases") async def get_macro_phases_list(): """Gibt alle Makro-Phasen zurueck.""" return { "macro_phases": [ {"id": "onboarding", "label": "Einrichtung", "order": 1}, {"id": "schuljahresstart", "label": "Schuljahresstart", "order": 2}, {"id": "unterrichtsaufbau", "label": "Unterrichtsaufbau", "order": 3}, {"id": "leistungsphase_1", "label": "Leistungsphase 1", "order": 4}, {"id": "halbjahresabschluss", "label": "Halbjahresabschluss", "order": 5}, {"id": "leistungsphase_2", "label": "Leistungsphase 2", "order": 6}, {"id": "jahresabschluss", "label": "Jahresabschluss", "order": 7}, ] } @router.get("/event-types") async def get_event_types_list(): """Gibt alle Event-Typen zurueck.""" return { "event_types": [ {"id": "exam", "label": "Klassenarbeit/Klausur"}, {"id": "parent_evening", "label": "Elternabend"}, {"id": "trip", "label": "Klassenfahrt/Ausflug"}, {"id": "project", "label": "Projektwoche"}, {"id": "other", "label": "Sonstiges"}, ] } @router.get("/routine-types") async def get_routine_types_list(): """Gibt alle Routine-Typen zurueck.""" return { "routine_types": [ {"id": "teacher_conference", "label": "Lehrerkonferenz"}, {"id": "subject_conference", "label": "Fachkonferenz"}, {"id": "office_hours", "label": "Sprechstunde"}, {"id": "correction_time", "label": "Korrekturzeit"}, {"id": "other", "label": "Sonstiges"}, ] } # === Antizipations-Engine === @router.get("/suggestions") async def get_suggestions(teacher_id: str = Query(...), limit: int = Query(5, ge=1, le=20)): """Generiert kontextbasierte Vorschlaege fuer einen Lehrer.""" if not DB_ENABLED: return { "active_contexts": [], "suggestions": [], "signals_summary": {"macro_phase": "onboarding"}, "total_suggestions": 0, } try: db = SessionLocal() generator = SuggestionGenerator(db) result = generator.generate(teacher_id, limit=limit) db.close() return result except Exception as e: logger.error(f"Failed to generate suggestions: {e}") raise HTTPException(status_code=500, detail=f"Fehler: {e}") # === Sidebar === @router.get("/sidebar") async def get_sidebar(teacher_id: str = Query(...), mode: str = Query("companion")): """Generiert das dynamische Sidebar-Model.""" if mode == "companion": return { "mode": "companion", "sections": [ {"id": "SEARCH", "type": "search_bar", "placeholder": "Suchen..."}, {"id": "NOW_RELEVANT", "type": "list", "title": "Jetzt relevant", "items": []}, { "id": "ALL_MODULES", "type": "folder", "label": "Alle Module", "collapsed": True, "items": [ {"id": "lesson", "label": "Stundenmodus", "icon": "timer"}, {"id": "classes", "label": "Klassen", "icon": "groups"}, {"id": "exams", "label": "Klausuren", "icon": "quiz"}, ], }, ], } return { "mode": "classic", "sections": [ { "id": "NAVIGATION", "type": "tree", "items": [ {"id": "dashboard", "label": "Dashboard", "icon": "dashboard"}, {"id": "lesson", "label": "Stundenmodus", "icon": "timer"}, {"id": "classes", "label": "Klassen", "icon": "groups"}, ], } ], } # === Schuljahres-Pfad === @router.get("/path") async def get_schoolyear_path(teacher_id: str = Query(...)): """Generiert den Schuljahres-Pfad mit Meilensteinen.""" current_phase = "onboarding" if DB_ENABLED: try: db = SessionLocal() repo = TeacherContextRepository(db) context = repo.get_or_create(teacher_id) current_phase = context.macro_phase.value db.close() except Exception as e: logger.warning(f"Failed to get context for path: {e}") phase_order = [ "onboarding", "schuljahresstart", "unterrichtsaufbau", "leistungsphase_1", "halbjahresabschluss", "leistungsphase_2", "jahresabschluss", ] current_index = phase_order.index(current_phase) if current_phase in phase_order else 0 milestones = [ {"id": "MS_START", "label": "Start", "phase": "onboarding"}, {"id": "MS_SETUP", "label": "Einrichtung", "phase": "schuljahresstart"}, {"id": "MS_ROUTINE", "label": "Routinen", "phase": "unterrichtsaufbau"}, {"id": "MS_EXAM_1", "label": "Klausuren", "phase": "leistungsphase_1"}, {"id": "MS_HALFYEAR", "label": "Halbjahr", "phase": "halbjahresabschluss"}, {"id": "MS_EXAM_2", "label": "Pruefungen", "phase": "leistungsphase_2"}, {"id": "MS_END", "label": "Abschluss", "phase": "jahresabschluss"}, ] for i, milestone in enumerate(milestones): phase_index = phase_order.index(milestone["phase"]) if phase_index < current_index: milestone["status"] = "done" elif phase_index == current_index: milestone["status"] = "current" else: milestone["status"] = "upcoming" return { "milestones": milestones, "current_milestone_id": milestones[current_index]["id"], "progress_percent": int((current_index / (len(phase_order) - 1)) * 100), }