1"""Professor dashboard: analytics, content ingestion, agent settings, announcements."""23from __future__ import annotations45import asyncio6import tempfile7from pathlib import Path8from typing import Any910from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status11from pydantic import BaseModel12from sqlalchemy import delete, select1314from app.core.config import Settings, get_settings15from app.core.security import AuthUser, require_professor16from app.db import SessionLocal17from app.models import CourseChunk, CourseDocument18from app.rag import retriever19from app.rag.ingest import ingest_file20from app.services import analytics, costs, users21from app.services import courses as course_service22from app.tools.all import registry2324router = APIRouter(prefix="/professor", tags=["professor"])2526INGEST_JOBS: dict[str, dict[str, Any]] = {}272829@router.get("/dashboard")30async def dashboard(days: int = 30, _: AuthUser = Depends(require_professor)) -> dict:31 data = await analytics.dashboard(days)32 data["budget"] = await costs.budget_status()33 data["costs_by_course"] = await costs.costs_by_course()34 return data353637@router.get("/settings")38async def get_settings_(_: AuthUser = Depends(require_professor),39 settings: Settings = Depends(get_settings)) -> dict:40 courses = await course_service.list_courses(include_private=True)41 return {"courses": courses, "tools": registry.names(),42 "models": {"primary": settings.MODEL_TUTOR_PRIMARY,43 "fallback": settings.MODEL_TUTOR_FALLBACK,44 "reasoning": settings.MODEL_REASONING, "fast": settings.MODEL_FAST},45 "budget_usd": settings.LLM_MONTHLY_BUDGET_USD}464748class CourseSettingsReq(BaseModel):49 extra_system_prompt: str | None = None50 announcement: str | None = None51 deadlines: list[dict[str, Any]] | None = None52 suggestions: list[str] | None = None53 settings: dict[str, Any] | None = None545556@router.patch("/settings/{course}")57async def patch_settings(course: str, req: CourseSettingsReq,58 _: AuthUser = Depends(require_professor)) -> dict:59 data = await course_service.update_course(course, req.model_dump(exclude_none=True))60 if not data:61 raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Cours introuvable.")62 return data636465@router.get("/content")66async def list_content(_: AuthUser = Depends(require_professor)) -> dict:67 async with SessionLocal() as session:68 rows = (await session.execute(select(CourseDocument)69 .order_by(CourseDocument.course_code,70 CourseDocument.filename))).scalars()71 docs = [{"id": d.id, "course": d.course_code, "filename": d.filename, "title": d.title,72 "visibility": d.visibility, "n_chunks": d.n_chunks,73 "ingested_at": d.ingested_at.isoformat()} for d in rows]74 return {"documents": docs, "index_size": len(retriever.index.docs), "jobs": INGEST_JOBS}757677async def _ingest_job(job_id: str, course: str, path: Path, visibility: str, title: str) -> None:78 INGEST_JOBS[job_id]["status"] = "running"79 try:80 n = await ingest_file(course, path, visibility, module=title or None)81 await retriever.rebuild_index()82 INGEST_JOBS[job_id].update({"status": "done", "chunks": n})83 except Exception as exc: # noqa: BLE00184 INGEST_JOBS[job_id].update({"status": "error", "error": str(exc)[:300]})85 finally:86 path.unlink(missing_ok=True)878889@router.post("/content", status_code=202)90async def upload_content(file: UploadFile = File(...), course: str = Form(...),91 visibility: str = Form("students"), title: str = Form(""),92 _: AuthUser = Depends(require_professor),93 settings: Settings = Depends(get_settings)) -> dict:94 course = course.upper()95 if course not in settings.courses:96 raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Cours inconnu.")97 if visibility not in {"students", "professor_only"}:98 raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Visibilité invalide.")99 suffix = Path(file.filename or "doc").suffix.lower()100 if suffix not in {".pdf", ".docx", ".pptx", ".md", ".txt", ".tex", ".html"}:101 raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Format non pris en charge.")102 data = await file.read()103 tmp_dir = settings.DATA_DIR / "ingest"104 tmp_dir.mkdir(exist_ok=True)105 tmp = Path(tempfile.mkstemp(suffix=suffix, prefix="up_", dir=tmp_dir)[1])106 tmp.write_bytes(data)107 # keep original filename for the document record108 target = tmp_dir / f"{tmp.stem}__{Path(file.filename or 'doc').name}"109 tmp.rename(target)110 job_id = target.stem[:12]111 INGEST_JOBS[job_id] = {"status": "queued", "filename": file.filename, "course": course}112 asyncio.create_task(_ingest_job(job_id, course, target, visibility, title))113 return {"job_id": job_id}114115116class VisibilityReq(BaseModel):117 visibility: str118119120@router.patch("/content/{doc_id}")121async def set_visibility(doc_id: str, req: VisibilityReq,122 _: AuthUser = Depends(require_professor)) -> dict:123 async with SessionLocal() as session:124 doc = await session.get(CourseDocument, doc_id)125 if not doc:126 raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Document introuvable.")127 doc.visibility = req.visibility128 rows = (await session.execute(select(CourseChunk)129 .where(CourseChunk.document_id == doc_id))).scalars()130 for c in rows:131 c.visibility = req.visibility132 await session.commit()133 await retriever.rebuild_index()134 return {"ok": True}135136137@router.delete("/content/{doc_id}")138async def delete_doc(doc_id: str, _: AuthUser = Depends(require_professor)) -> dict:139 async with SessionLocal() as session:140 doc = await session.get(CourseDocument, doc_id)141 if not doc:142 raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Document introuvable.")143 await session.execute(delete(CourseChunk).where(CourseChunk.document_id == doc_id))144 await session.delete(doc)145 await session.commit()146 await retriever.rebuild_index()147 return {"ok": True}148149150class PromoteReq(BaseModel):151 email: str152 role: str = "professor"153154155@router.post("/promote")156async def promote(req: PromoteReq, _: AuthUser = Depends(require_professor)) -> dict:157 if req.role not in {"student", "professor"}:158 raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Rôle invalide.")159 if not await users.set_role(req.email, req.role):160 raise HTTPException(status.HTTP_404_NOT_FOUND,161 detail="Compte introuvable (la personne doit s'être connectée une fois).")162 return {"ok": True}163