Python 64.6%
TypeScript 33.7%
CSS 0.8%
1"""Professor dashboard: analytics, content ingestion, agent settings, announcements."""23from __future__ import annotations45import asyncio6import re7import tempfile8from pathlib import Path9from typing import Any1011from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status12from pydantic import BaseModel13from sqlalchemy import delete, select1415from app.core.config import Settings, get_settings16from app.core.security import AuthUser, require_professor17from app.db import SessionLocal18from app.models import CourseChunk, CourseDocument19from app.rag import retriever20from app.rag.ingest import ingest_file21from app.services import analytics, costs, invites, users22from app.services import courses as course_service23from app.tools.all import registry2425router = APIRouter(prefix="/professor", tags=["professor"])2627INGEST_JOBS: dict[str, dict[str, Any]] = {}282930@router.get("/dashboard")31async def dashboard(days: int = 30, _: AuthUser = Depends(require_professor)) -> dict:32 data = await analytics.dashboard(days)33 data["budget"] = await costs.budget_status()34 data["costs_by_course"] = await costs.costs_by_course()35 return data363738@router.get("/settings")39async def get_settings_(_: AuthUser = Depends(require_professor),40 settings: Settings = Depends(get_settings)) -> dict:41 courses = await course_service.list_courses(include_private=True)42 return {"courses": courses, "tools": registry.names(),43 "models": {"primary": settings.MODEL_TUTOR_PRIMARY,44 "fallback": settings.MODEL_TUTOR_FALLBACK,45 "reasoning": settings.MODEL_REASONING, "fast": settings.MODEL_FAST},46 "budget_usd": settings.LLM_MONTHLY_BUDGET_USD}474849class CourseSettingsReq(BaseModel):50 extra_system_prompt: str | None = None51 announcement: str | None = None52 deadlines: list[dict[str, Any]] | None = None53 suggestions: list[str] | None = None54 settings: dict[str, Any] | None = None555657@router.patch("/settings/{course}")58async def patch_settings(course: str, req: CourseSettingsReq,59 _: AuthUser = Depends(require_professor)) -> dict:60 data = await course_service.update_course(course, req.model_dump(exclude_none=True))61 if not data:62 raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Cours introuvable.")63 return data646566@router.get("/content")67async def list_content(_: AuthUser = Depends(require_professor)) -> dict:68 async with SessionLocal() as session:69 rows = (await session.execute(select(CourseDocument)70 .order_by(CourseDocument.course_code,71 CourseDocument.filename))).scalars()72 docs = [{"id": d.id, "course": d.course_code, "filename": d.filename, "title": d.title,73 "visibility": d.visibility, "n_chunks": d.n_chunks,74 "ingested_at": d.ingested_at.isoformat()} for d in rows]75 return {"documents": docs, "index_size": len(retriever.index.docs), "jobs": INGEST_JOBS}767778async def _ingest_job(job_id: str, course: str, path: Path, visibility: str, title: str) -> None:79 INGEST_JOBS[job_id]["status"] = "running"80 try:81 n = await ingest_file(course, path, visibility, module=title or None)82 await retriever.rebuild_index()83 INGEST_JOBS[job_id].update({"status": "done", "chunks": n})84 except Exception as exc: # noqa: BLE00185 INGEST_JOBS[job_id].update({"status": "error", "error": str(exc)[:300]})86 finally:87 path.unlink(missing_ok=True)888990@router.post("/content", status_code=202)91async def upload_content(file: UploadFile = File(...), course: str = Form(...),92 visibility: str = Form("students"), title: str = Form(""),93 _: AuthUser = Depends(require_professor),94 settings: Settings = Depends(get_settings)) -> dict:95 course = course.upper()96 if course not in settings.courses:97 raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Cours inconnu.")98 if visibility not in {"students", "professor_only"}:99 raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Visibilité invalide.")100 suffix = Path(file.filename or "doc").suffix.lower()101 if suffix not in {".pdf", ".docx", ".pptx", ".md", ".txt", ".tex", ".html"}:102 raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Format non pris en charge.")103 data = await file.read()104 tmp_dir = settings.DATA_DIR / "ingest"105 tmp_dir.mkdir(exist_ok=True)106 tmp = Path(tempfile.mkstemp(suffix=suffix, prefix="up_", dir=tmp_dir)[1])107 tmp.write_bytes(data)108 # keep original filename for the document record109 target = tmp_dir / f"{tmp.stem}__{Path(file.filename or 'doc').name}"110 tmp.rename(target)111 job_id = target.stem[:12]112 INGEST_JOBS[job_id] = {"status": "queued", "filename": file.filename, "course": course}113 asyncio.create_task(_ingest_job(job_id, course, target, visibility, title))114 return {"job_id": job_id}115116117class VisibilityReq(BaseModel):118 visibility: str119120121@router.patch("/content/{doc_id}")122async def set_visibility(doc_id: str, req: VisibilityReq,123 _: AuthUser = Depends(require_professor)) -> dict:124 async with SessionLocal() as session:125 doc = await session.get(CourseDocument, doc_id)126 if not doc:127 raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Document introuvable.")128 doc.visibility = req.visibility129 rows = (await session.execute(select(CourseChunk)130 .where(CourseChunk.document_id == doc_id))).scalars()131 for c in rows:132 c.visibility = req.visibility133 await session.commit()134 await retriever.rebuild_index()135 return {"ok": True}136137138@router.delete("/content/{doc_id}")139async def delete_doc(doc_id: str, _: AuthUser = Depends(require_professor)) -> dict:140 async with SessionLocal() as session:141 doc = await session.get(CourseDocument, doc_id)142 if not doc:143 raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Document introuvable.")144 await session.execute(delete(CourseChunk).where(CourseChunk.document_id == doc_id))145 await session.delete(doc)146 await session.commit()147 await retriever.rebuild_index()148 return {"ok": True}149150151class PromoteReq(BaseModel):152 email: str153 role: str = "professor"154155156@router.post("/promote")157async def promote(req: PromoteReq, _: AuthUser = Depends(require_professor)) -> dict:158 if req.role not in {"student", "professor"}:159 raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Rôle invalide.")160 if not await users.set_role(req.email, req.role):161 raise HTTPException(status.HTTP_404_NOT_FOUND,162 detail="Compte introuvable (la personne doit s'être connectée une fois).")163 return {"ok": True}164165166# ------------------------------------------------------------------ students167@router.get("/students")168async def list_students(_: AuthUser = Depends(require_professor),169 settings: Settings = Depends(get_settings)) -> dict:170 rows = await users.list_users()171 return {"users": rows, "mail": settings.mail_enabled,172 "mail_from": settings.MAIL_FROM if settings.resend_enabled else settings.SMTP_FROM,173 "invite_ttl_days": settings.INVITE_TTL_DAYS,174 "pending": sum(1 for u in rows if not u["has_password"])}175176177class CreateStudentsReq(BaseModel):178 emails: list[str] | str179 role: str = "student"180 send_invitations: bool = True181182183@router.post("/students", status_code=201)184async def create_students(req: CreateStudentsReq, _: AuthUser = Depends(require_professor),185 settings: Settings = Depends(get_settings)) -> dict:186 """Register addresses; each new account receives a 'choose your password' e-mail."""187 raw = req.emails if isinstance(req.emails, list) else re.split(r"[\n;,]+", req.emails)188 names: dict[str, str] = {}189 emails: list[str] = []190 for item in raw:191 item = item.strip()192 if not item:193 continue194 m = re.match(r"^(.*?)<([^>]+)>$", item) # "Prénom Nom <courriel>"195 if m:196 names[m.group(2).strip().lower()] = m.group(1).strip().strip('"')197 emails.append(m.group(2))198 else:199 emails.extend(t for t in re.split(r"\s+", item) if t)200 if req.role not in {"student", "professor"}:201 raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Rôle invalide.")202 result = await users.create_users(emails[:500], settings, req.role, names)203 created_ids = result.pop("created_ids", [])204 result["invited"], result["invite_failed"] = [], []205 if req.send_invitations and created_ids:206 targets = [u for u in [await users.get_user(i) for i in created_ids] if u]207 outcome = await invites.invite_users(targets, settings)208 result["invited"], result["invite_failed"] = outcome["sent"], outcome["failed"]209 result["mail"] = settings.mail_enabled210 return result211212213class InviteAllReq(BaseModel):214 only_never_invited: bool = True215216217@router.post("/students/invite-all")218async def invite_all(req: InviteAllReq, _: AuthUser = Depends(require_professor),219 settings: Settings = Depends(get_settings)) -> dict:220 """(Re)send the activation e-mail to every account that has no password yet."""221 if not settings.mail_enabled:222 raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Envoi de courriel non configuré (RESEND_API_KEY).")223 targets = await users.users_without_password(only_never_invited=req.only_never_invited)224 outcome = await invites.invite_users(targets, settings)225 return {"sent": outcome["sent"], "failed": outcome["failed"], "total": len(targets)}226227228class RoleReq(BaseModel):229 role: str230231232@router.patch("/students/{user_id}")233async def set_student_role(user_id: str, req: RoleReq, auth: AuthUser = Depends(require_professor)) -> dict:234 if req.role not in {"student", "professor"}:235 raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Rôle invalide.")236 u = await users.get_user(user_id)237 if not u:238 raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Compte introuvable.")239 if u.id == auth.id:240 raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Impossible de modifier ton propre rôle.")241 await users.set_role(u.email, req.role)242 return {"ok": True}243244245@router.delete("/students/{user_id}")246async def delete_student(user_id: str, auth: AuthUser = Depends(require_professor)) -> dict:247 u = await users.get_user(user_id)248 if not u:249 raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Compte introuvable.")250 if u.id == auth.id or u.role == "admin":251 raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Ce compte ne peut pas être supprimé ici.")252 await users.delete_user_data(user_id)253 return {"ok": True}254255256@router.post("/students/{user_id}/invite")257async def invite_student(user_id: str, _: AuthUser = Depends(require_professor),258 settings: Settings = Depends(get_settings)) -> dict:259 """(Re)send the 'choose your password' e-mail; the link is also returned so it can be copied."""260 u = await users.get_user(user_id)261 if not u:262 raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Compte introuvable.")263 outcome = await invites.invite_users([u], settings)264 return {"link": outcome["links"][u.id], "sent": u.email in outcome["sent"],265 "expires_days": settings.INVITE_TTL_DAYS}266267268class SetPasswordReq(BaseModel):269 password: str270271272@router.put("/students/{user_id}/password")273async def set_student_password(user_id: str, req: SetPasswordReq,274 auth: AuthUser = Depends(require_professor)) -> dict:275 if len(req.password) < 8:276 raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Mot de passe trop court (8 caractères minimum).")277 u = await users.get_user(user_id)278 if not u:279 raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Compte introuvable.")280 if u.role == "admin" and auth.role != "admin":281 raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Réservé à l'administrateur.")282 await users.set_password(user_id, req.password)283 return {"ok": True}284