"""Professor dashboard: analytics, content ingestion, agent settings, announcements.""" from __future__ import annotations import asyncio import re import tempfile from pathlib import Path from typing import Any from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status from pydantic import BaseModel from sqlalchemy import delete, select from app.core.config import Settings, get_settings from app.core.security import AuthUser, require_professor from app.db import SessionLocal from app.models import CourseChunk, CourseDocument from app.rag import retriever from app.rag.ingest import ingest_file from app.services import analytics, costs, invites, users from app.services import courses as course_service from app.tools.all import registry router = APIRouter(prefix="/professor", tags=["professor"]) INGEST_JOBS: dict[str, dict[str, Any]] = {} @router.get("/dashboard") async def dashboard(days: int = 30, _: AuthUser = Depends(require_professor)) -> dict: data = await analytics.dashboard(days) data["budget"] = await costs.budget_status() data["costs_by_course"] = await costs.costs_by_course() return data @router.get("/settings") async def get_settings_(_: AuthUser = Depends(require_professor), settings: Settings = Depends(get_settings)) -> dict: courses = await course_service.list_courses(include_private=True) return {"courses": courses, "tools": registry.names(), "models": {"primary": settings.MODEL_TUTOR_PRIMARY, "fallback": settings.MODEL_TUTOR_FALLBACK, "reasoning": settings.MODEL_REASONING, "fast": settings.MODEL_FAST}, "budget_usd": settings.LLM_MONTHLY_BUDGET_USD} class CourseSettingsReq(BaseModel): extra_system_prompt: str | None = None announcement: str | None = None deadlines: list[dict[str, Any]] | None = None suggestions: list[str] | None = None settings: dict[str, Any] | None = None @router.patch("/settings/{course}") async def patch_settings(course: str, req: CourseSettingsReq, _: AuthUser = Depends(require_professor)) -> dict: data = await course_service.update_course(course, req.model_dump(exclude_none=True)) if not data: raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Cours introuvable.") return data @router.get("/content") async def list_content(_: AuthUser = Depends(require_professor)) -> dict: async with SessionLocal() as session: rows = (await session.execute(select(CourseDocument) .order_by(CourseDocument.course_code, CourseDocument.filename))).scalars() docs = [{"id": d.id, "course": d.course_code, "filename": d.filename, "title": d.title, "visibility": d.visibility, "n_chunks": d.n_chunks, "ingested_at": d.ingested_at.isoformat()} for d in rows] return {"documents": docs, "index_size": len(retriever.index.docs), "jobs": INGEST_JOBS} async def _ingest_job(job_id: str, course: str, path: Path, visibility: str, title: str) -> None: INGEST_JOBS[job_id]["status"] = "running" try: n = await ingest_file(course, path, visibility, module=title or None) await retriever.rebuild_index() INGEST_JOBS[job_id].update({"status": "done", "chunks": n}) except Exception as exc: # noqa: BLE001 INGEST_JOBS[job_id].update({"status": "error", "error": str(exc)[:300]}) finally: path.unlink(missing_ok=True) @router.post("/content", status_code=202) async def upload_content(file: UploadFile = File(...), course: str = Form(...), visibility: str = Form("students"), title: str = Form(""), _: AuthUser = Depends(require_professor), settings: Settings = Depends(get_settings)) -> dict: course = course.upper() if course not in settings.courses: raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Cours inconnu.") if visibility not in {"students", "professor_only"}: raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Visibilité invalide.") suffix = Path(file.filename or "doc").suffix.lower() if suffix not in {".pdf", ".docx", ".pptx", ".md", ".txt", ".tex", ".html"}: raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Format non pris en charge.") data = await file.read() tmp_dir = settings.DATA_DIR / "ingest" tmp_dir.mkdir(exist_ok=True) tmp = Path(tempfile.mkstemp(suffix=suffix, prefix="up_", dir=tmp_dir)[1]) tmp.write_bytes(data) # keep original filename for the document record target = tmp_dir / f"{tmp.stem}__{Path(file.filename or 'doc').name}" tmp.rename(target) job_id = target.stem[:12] INGEST_JOBS[job_id] = {"status": "queued", "filename": file.filename, "course": course} asyncio.create_task(_ingest_job(job_id, course, target, visibility, title)) return {"job_id": job_id} class VisibilityReq(BaseModel): visibility: str @router.patch("/content/{doc_id}") async def set_visibility(doc_id: str, req: VisibilityReq, _: AuthUser = Depends(require_professor)) -> dict: async with SessionLocal() as session: doc = await session.get(CourseDocument, doc_id) if not doc: raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Document introuvable.") doc.visibility = req.visibility rows = (await session.execute(select(CourseChunk) .where(CourseChunk.document_id == doc_id))).scalars() for c in rows: c.visibility = req.visibility await session.commit() await retriever.rebuild_index() return {"ok": True} @router.delete("/content/{doc_id}") async def delete_doc(doc_id: str, _: AuthUser = Depends(require_professor)) -> dict: async with SessionLocal() as session: doc = await session.get(CourseDocument, doc_id) if not doc: raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Document introuvable.") await session.execute(delete(CourseChunk).where(CourseChunk.document_id == doc_id)) await session.delete(doc) await session.commit() await retriever.rebuild_index() return {"ok": True} class PromoteReq(BaseModel): email: str role: str = "professor" @router.post("/promote") async def promote(req: PromoteReq, _: AuthUser = Depends(require_professor)) -> dict: if req.role not in {"student", "professor"}: raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Rôle invalide.") if not await users.set_role(req.email, req.role): raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Compte introuvable (la personne doit s'être connectée une fois).") return {"ok": True} # ------------------------------------------------------------------ students @router.get("/students") async def list_students(_: AuthUser = Depends(require_professor), settings: Settings = Depends(get_settings)) -> dict: rows = await users.list_users() return {"users": rows, "mail": settings.mail_enabled, "mail_from": settings.MAIL_FROM if settings.resend_enabled else settings.SMTP_FROM, "invite_ttl_days": settings.INVITE_TTL_DAYS, "pending": sum(1 for u in rows if not u["has_password"])} class CreateStudentsReq(BaseModel): emails: list[str] | str role: str = "student" send_invitations: bool = True @router.post("/students", status_code=201) async def create_students(req: CreateStudentsReq, _: AuthUser = Depends(require_professor), settings: Settings = Depends(get_settings)) -> dict: """Register addresses; each new account receives a 'choose your password' e-mail.""" raw = req.emails if isinstance(req.emails, list) else re.split(r"[\n;,]+", req.emails) names: dict[str, str] = {} emails: list[str] = [] for item in raw: item = item.strip() if not item: continue m = re.match(r"^(.*?)<([^>]+)>$", item) # "Prénom Nom " if m: names[m.group(2).strip().lower()] = m.group(1).strip().strip('"') emails.append(m.group(2)) else: emails.extend(t for t in re.split(r"\s+", item) if t) if req.role not in {"student", "professor"}: raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Rôle invalide.") result = await users.create_users(emails[:500], settings, req.role, names) created_ids = result.pop("created_ids", []) result["invited"], result["invite_failed"] = [], [] if req.send_invitations and created_ids: targets = [u for u in [await users.get_user(i) for i in created_ids] if u] outcome = await invites.invite_users(targets, settings) result["invited"], result["invite_failed"] = outcome["sent"], outcome["failed"] result["mail"] = settings.mail_enabled return result class InviteAllReq(BaseModel): only_never_invited: bool = True @router.post("/students/invite-all") async def invite_all(req: InviteAllReq, _: AuthUser = Depends(require_professor), settings: Settings = Depends(get_settings)) -> dict: """(Re)send the activation e-mail to every account that has no password yet.""" if not settings.mail_enabled: raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Envoi de courriel non configuré (RESEND_API_KEY).") targets = await users.users_without_password(only_never_invited=req.only_never_invited) outcome = await invites.invite_users(targets, settings) return {"sent": outcome["sent"], "failed": outcome["failed"], "total": len(targets)} class RoleReq(BaseModel): role: str @router.patch("/students/{user_id}") async def set_student_role(user_id: str, req: RoleReq, auth: AuthUser = Depends(require_professor)) -> dict: if req.role not in {"student", "professor"}: raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Rôle invalide.") u = await users.get_user(user_id) if not u: raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Compte introuvable.") if u.id == auth.id: raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Impossible de modifier ton propre rôle.") await users.set_role(u.email, req.role) return {"ok": True} @router.delete("/students/{user_id}") async def delete_student(user_id: str, auth: AuthUser = Depends(require_professor)) -> dict: u = await users.get_user(user_id) if not u: raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Compte introuvable.") if u.id == auth.id or u.role == "admin": raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Ce compte ne peut pas être supprimé ici.") await users.delete_user_data(user_id) return {"ok": True} @router.post("/students/{user_id}/invite") async def invite_student(user_id: str, _: AuthUser = Depends(require_professor), settings: Settings = Depends(get_settings)) -> dict: """(Re)send the 'choose your password' e-mail; the link is also returned so it can be copied.""" u = await users.get_user(user_id) if not u: raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Compte introuvable.") outcome = await invites.invite_users([u], settings) return {"link": outcome["links"][u.id], "sent": u.email in outcome["sent"], "expires_days": settings.INVITE_TTL_DAYS} class SetPasswordReq(BaseModel): password: str @router.put("/students/{user_id}/password") async def set_student_password(user_id: str, req: SetPasswordReq, auth: AuthUser = Depends(require_professor)) -> dict: if len(req.password) < 8: raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Mot de passe trop court (8 caractères minimum).") u = await users.get_user(user_id) if not u: raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Compte introuvable.") if u.role == "admin" and auth.role != "admin": raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Réservé à l'administrateur.") await users.set_password(user_id, req.password) return {"ok": True}