This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
breakpilot-pwa/backend/api/classroom/context.py
Benjamin Admin 21a844cb8a fix: Restore all files lost during destructive rebase
A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.

This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).

Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 09:51:32 +01:00

688 lines
23 KiB
Python

"""
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),
}