Some checks failed
Tests / Go Tests (push) Has been cancelled
Tests / Python Tests (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / Go Lint (push) Has been cancelled
Tests / Python Lint (push) Has been cancelled
Tests / Security Scan (push) Has been cancelled
Tests / All Checks Passed (push) Has been cancelled
Security Scanning / Secret Scanning (push) Has been cancelled
Security Scanning / Dependency Vulnerability Scan (push) Has been cancelled
Security Scanning / Go Security Scan (push) Has been cancelled
Security Scanning / Python Security Scan (push) Has been cancelled
Security Scanning / Node.js Security Scan (push) Has been cancelled
Security Scanning / Docker Image Security (push) Has been cancelled
Security Scanning / Security Summary (push) Has been cancelled
CI/CD Pipeline / Go Tests (push) Has been cancelled
CI/CD Pipeline / Python Tests (push) Has been cancelled
CI/CD Pipeline / Website Tests (push) Has been cancelled
CI/CD Pipeline / Linting (push) Has been cancelled
CI/CD Pipeline / Security Scan (push) Has been cancelled
CI/CD Pipeline / Docker Build & Push (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Deploy to Staging (push) Has been cancelled
CI/CD Pipeline / Deploy to Production (push) Has been cancelled
CI/CD Pipeline / CI Summary (push) Has been cancelled
ci/woodpecker/manual/build-ci-image Pipeline was successful
ci/woodpecker/manual/main Pipeline failed
All services: admin-v2, studio-v2, website, ai-compliance-sdk, consent-service, klausur-service, voice-service, and infrastructure. Large PDFs and compiled binaries excluded via .gitignore.
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}
|