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>
344 lines
11 KiB
Python
344 lines
11 KiB
Python
"""
|
|
Classroom API - Materials Endpoints.
|
|
|
|
Endpoints fuer Unterrichtsmaterialien (Feature f19).
|
|
"""
|
|
|
|
from uuid import uuid4
|
|
from typing import Dict, List, Optional
|
|
from datetime import datetime
|
|
import logging
|
|
|
|
from fastapi import APIRouter, HTTPException, Query
|
|
from pydantic import BaseModel, Field
|
|
|
|
from classroom_engine import PhaseMaterial, MaterialType
|
|
|
|
from .shared import init_db_if_needed, DB_ENABLED, logger
|
|
|
|
try:
|
|
from classroom_engine.database import SessionLocal
|
|
from classroom_engine.repository import MaterialRepository
|
|
except ImportError:
|
|
pass
|
|
|
|
router = APIRouter(tags=["Materials"])
|
|
|
|
# In-Memory Storage (Fallback)
|
|
_materials: Dict[str, PhaseMaterial] = {}
|
|
|
|
|
|
# === Pydantic Models ===
|
|
|
|
class CreateMaterialRequest(BaseModel):
|
|
"""Request zum Erstellen eines Materials."""
|
|
teacher_id: str
|
|
title: str = Field(..., max_length=300)
|
|
material_type: str = Field("document")
|
|
url: Optional[str] = Field(None, max_length=2000)
|
|
description: str = ""
|
|
phase: Optional[str] = None
|
|
subject: str = ""
|
|
grade_level: str = ""
|
|
tags: List[str] = []
|
|
is_public: bool = False
|
|
session_id: Optional[str] = None
|
|
|
|
|
|
class UpdateMaterialRequest(BaseModel):
|
|
"""Request zum Aktualisieren eines Materials."""
|
|
title: Optional[str] = Field(None, max_length=300)
|
|
material_type: Optional[str] = None
|
|
url: Optional[str] = Field(None, max_length=2000)
|
|
description: Optional[str] = None
|
|
phase: Optional[str] = None
|
|
subject: Optional[str] = None
|
|
grade_level: Optional[str] = None
|
|
tags: Optional[List[str]] = None
|
|
is_public: Optional[bool] = None
|
|
|
|
|
|
class MaterialResponse(BaseModel):
|
|
"""Response fuer ein Material."""
|
|
material_id: str
|
|
teacher_id: str
|
|
title: str
|
|
material_type: str
|
|
url: Optional[str]
|
|
description: str
|
|
phase: Optional[str]
|
|
subject: str
|
|
grade_level: str
|
|
tags: List[str]
|
|
is_public: bool
|
|
usage_count: int
|
|
session_id: Optional[str]
|
|
created_at: Optional[str]
|
|
updated_at: Optional[str]
|
|
|
|
|
|
class MaterialListResponse(BaseModel):
|
|
"""Response fuer Liste von Materialien."""
|
|
materials: List[MaterialResponse]
|
|
total: int
|
|
|
|
|
|
# === Helper Functions ===
|
|
|
|
def build_material_response(mat: PhaseMaterial) -> MaterialResponse:
|
|
"""Baut eine MaterialResponse aus einem PhaseMaterial-Objekt."""
|
|
return MaterialResponse(
|
|
material_id=mat.material_id,
|
|
teacher_id=mat.teacher_id,
|
|
title=mat.title,
|
|
material_type=mat.material_type.value,
|
|
url=mat.url,
|
|
description=mat.description,
|
|
phase=mat.phase,
|
|
subject=mat.subject,
|
|
grade_level=mat.grade_level,
|
|
tags=mat.tags,
|
|
is_public=mat.is_public,
|
|
usage_count=mat.usage_count,
|
|
session_id=mat.session_id,
|
|
created_at=mat.created_at.isoformat() if mat.created_at else None,
|
|
updated_at=mat.updated_at.isoformat() if mat.updated_at else None,
|
|
)
|
|
|
|
|
|
# === Endpoints ===
|
|
|
|
@router.post("/materials", response_model=MaterialResponse, status_code=201)
|
|
async def create_material(request: CreateMaterialRequest) -> MaterialResponse:
|
|
"""Erstellt ein neues Material (Feature f19)."""
|
|
init_db_if_needed()
|
|
|
|
try:
|
|
mat_type = MaterialType(request.material_type)
|
|
except ValueError:
|
|
mat_type = MaterialType.DOCUMENT
|
|
|
|
material = PhaseMaterial(
|
|
material_id=str(uuid4()),
|
|
teacher_id=request.teacher_id,
|
|
title=request.title,
|
|
material_type=mat_type,
|
|
url=request.url,
|
|
description=request.description,
|
|
phase=request.phase,
|
|
subject=request.subject,
|
|
grade_level=request.grade_level,
|
|
tags=request.tags,
|
|
is_public=request.is_public,
|
|
usage_count=0,
|
|
session_id=request.session_id,
|
|
created_at=datetime.utcnow(),
|
|
)
|
|
|
|
if DB_ENABLED:
|
|
try:
|
|
db = SessionLocal()
|
|
repo = MaterialRepository(db)
|
|
repo.create(material)
|
|
db.close()
|
|
except Exception as e:
|
|
logger.warning(f"DB persist failed for material: {e}")
|
|
|
|
_materials[material.material_id] = material
|
|
return build_material_response(material)
|
|
|
|
|
|
@router.get("/materials", response_model=MaterialListResponse)
|
|
async def list_materials(
|
|
teacher_id: str = Query(...),
|
|
phase: Optional[str] = Query(None),
|
|
subject: Optional[str] = Query(None),
|
|
include_public: bool = Query(True),
|
|
limit: int = Query(50, ge=1, le=100)
|
|
) -> MaterialListResponse:
|
|
"""Listet Materialien eines Lehrers (Feature f19)."""
|
|
init_db_if_needed()
|
|
|
|
materials_list = []
|
|
|
|
if DB_ENABLED:
|
|
try:
|
|
db = SessionLocal()
|
|
repo = MaterialRepository(db)
|
|
if phase:
|
|
db_materials = repo.get_by_phase(phase, teacher_id, include_public)
|
|
else:
|
|
db_materials = repo.get_by_teacher(teacher_id, phase, subject, limit)
|
|
|
|
for db_mat in db_materials:
|
|
mat = repo.to_dataclass(db_mat)
|
|
_materials[mat.material_id] = mat
|
|
materials_list.append(build_material_response(mat))
|
|
db.close()
|
|
return MaterialListResponse(materials=materials_list, total=len(materials_list))
|
|
except Exception as e:
|
|
logger.warning(f"DB read failed for materials: {e}")
|
|
|
|
for mat in _materials.values():
|
|
if mat.teacher_id != teacher_id and not (include_public and mat.is_public):
|
|
continue
|
|
if phase and mat.phase != phase:
|
|
continue
|
|
if subject and mat.subject != subject:
|
|
continue
|
|
materials_list.append(build_material_response(mat))
|
|
|
|
return MaterialListResponse(materials=materials_list[:limit], total=len(materials_list))
|
|
|
|
|
|
@router.get("/materials/by-phase/{phase}", response_model=MaterialListResponse)
|
|
async def get_materials_by_phase(
|
|
phase: str,
|
|
teacher_id: str = Query(...),
|
|
subject: Optional[str] = Query(None),
|
|
limit: int = Query(50, ge=1, le=100)
|
|
) -> MaterialListResponse:
|
|
"""Holt Materialien fuer eine bestimmte Phase (Feature f19)."""
|
|
return await list_materials(teacher_id=teacher_id, phase=phase, subject=subject, limit=limit)
|
|
|
|
|
|
@router.get("/materials/{material_id}", response_model=MaterialResponse)
|
|
async def get_material(material_id: str) -> MaterialResponse:
|
|
"""Ruft ein einzelnes Material ab (Feature f19)."""
|
|
init_db_if_needed()
|
|
|
|
if material_id in _materials:
|
|
return build_material_response(_materials[material_id])
|
|
|
|
if DB_ENABLED:
|
|
try:
|
|
db = SessionLocal()
|
|
repo = MaterialRepository(db)
|
|
db_mat = repo.get_by_id(material_id)
|
|
db.close()
|
|
if db_mat:
|
|
mat = repo.to_dataclass(db_mat)
|
|
_materials[mat.material_id] = mat
|
|
return build_material_response(mat)
|
|
except Exception as e:
|
|
logger.warning(f"DB read failed: {e}")
|
|
|
|
raise HTTPException(status_code=404, detail="Material nicht gefunden")
|
|
|
|
|
|
@router.put("/materials/{material_id}", response_model=MaterialResponse)
|
|
async def update_material(material_id: str, request: UpdateMaterialRequest) -> MaterialResponse:
|
|
"""Aktualisiert ein Material (Feature f19)."""
|
|
init_db_if_needed()
|
|
|
|
material = _materials.get(material_id)
|
|
|
|
if not material and DB_ENABLED:
|
|
try:
|
|
db = SessionLocal()
|
|
repo = MaterialRepository(db)
|
|
db_mat = repo.get_by_id(material_id)
|
|
db.close()
|
|
if db_mat:
|
|
material = repo.to_dataclass(db_mat)
|
|
_materials[material.material_id] = material
|
|
except Exception as e:
|
|
logger.warning(f"DB read failed: {e}")
|
|
|
|
if not material:
|
|
raise HTTPException(status_code=404, detail="Material nicht gefunden")
|
|
|
|
if request.title is not None:
|
|
material.title = request.title
|
|
if request.material_type is not None:
|
|
try:
|
|
material.material_type = MaterialType(request.material_type)
|
|
except ValueError:
|
|
raise HTTPException(status_code=400, detail="Ungueltiger Material-Typ")
|
|
if request.url is not None:
|
|
material.url = request.url
|
|
if request.description is not None:
|
|
material.description = request.description
|
|
if request.phase is not None:
|
|
material.phase = request.phase
|
|
if request.subject is not None:
|
|
material.subject = request.subject
|
|
if request.grade_level is not None:
|
|
material.grade_level = request.grade_level
|
|
if request.tags is not None:
|
|
material.tags = request.tags
|
|
if request.is_public is not None:
|
|
material.is_public = request.is_public
|
|
|
|
material.updated_at = datetime.utcnow()
|
|
|
|
if DB_ENABLED:
|
|
try:
|
|
db = SessionLocal()
|
|
repo = MaterialRepository(db)
|
|
repo.update(material)
|
|
db.close()
|
|
except Exception as e:
|
|
logger.warning(f"DB update failed: {e}")
|
|
|
|
_materials[material_id] = material
|
|
return build_material_response(material)
|
|
|
|
|
|
@router.post("/materials/{material_id}/attach/{session_id}")
|
|
async def attach_material_to_session(material_id: str, session_id: str) -> MaterialResponse:
|
|
"""Verknuepft ein Material mit einer Session (Feature f19)."""
|
|
init_db_if_needed()
|
|
|
|
material = _materials.get(material_id)
|
|
|
|
if not material and DB_ENABLED:
|
|
try:
|
|
db = SessionLocal()
|
|
repo = MaterialRepository(db)
|
|
db_mat = repo.get_by_id(material_id)
|
|
if db_mat:
|
|
material = repo.to_dataclass(db_mat)
|
|
db.close()
|
|
except Exception as e:
|
|
logger.warning(f"DB read failed: {e}")
|
|
|
|
if not material:
|
|
raise HTTPException(status_code=404, detail="Material nicht gefunden")
|
|
|
|
material.session_id = session_id
|
|
material.usage_count += 1
|
|
material.updated_at = datetime.utcnow()
|
|
|
|
if DB_ENABLED:
|
|
try:
|
|
db = SessionLocal()
|
|
repo = MaterialRepository(db)
|
|
repo.attach_to_session(material_id, session_id)
|
|
db.close()
|
|
except Exception as e:
|
|
logger.warning(f"DB update failed: {e}")
|
|
|
|
_materials[material_id] = material
|
|
return build_material_response(material)
|
|
|
|
|
|
@router.delete("/materials/{material_id}")
|
|
async def delete_material(material_id: str) -> Dict[str, str]:
|
|
"""Loescht ein Material (Feature f19)."""
|
|
init_db_if_needed()
|
|
|
|
if material_id in _materials:
|
|
del _materials[material_id]
|
|
|
|
if DB_ENABLED:
|
|
try:
|
|
db = SessionLocal()
|
|
repo = MaterialRepository(db)
|
|
repo.delete(material_id)
|
|
db.close()
|
|
except Exception as e:
|
|
logger.warning(f"DB delete failed: {e}")
|
|
|
|
return {"status": "deleted", "material_id": material_id}
|