SPB Git forge

spb/uqo-chat

Public
14commits 1branches 0releases
1.4 MBsize
maindefault branch
17 days agolast push
Python 64.6% TypeScript 33.7% CSS 0.8%
12.1 KB · 284 lines python
Raw Blame History
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