"""Question folders: a hand-picked set an educator assembles and a grant points at. The access tree names combinations of exam, discipline and category, and covers whatever matches all three. That shape cannot express "these forty questions", which is what assigning work actually looks like — so a folder is the fourth thing a grant can name. Who may do what, and why: * Anyone with editorial access may make a folder and fill it with questions they already manage. A folder is not itself access, so making one hands out nothing. * Only a moderator may grant one (that lives in `access.py`), so nobody can widen their own reach by assembling a list. * Only the owner and moderators change membership. A grantee reads the folder and edits the questions in it; if they could add to it they would be writing their own grant. """ from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field, field_validator from sqlalchemy import func, or_ from sqlalchemy.orm import Session from app.database import get_db from app.models.category_grant import CategoryGrant from app.models.folder import QuestionFolder, QuestionFolderQuestion from app.models.question import Question from app.models.user import User from app.utils.auth import get_current_user from app.utils.category_grants import (assert_user_can_manage, granted_folder_ids, is_question_manager) router = APIRouter() MAX_BULK = 500 class FolderWrite(BaseModel): name: str = Field(min_length=1, max_length=200) description: str | None = Field(default=None, max_length=2000) @field_validator("name") @classmethod def trimmed(cls, value: str) -> str: value = value.strip() if not value: raise ValueError("A folder needs a name") return value class FolderQuestions(BaseModel): question_ids: list[int] = Field(min_length=1, max_length=MAX_BULK) def _require_manager(db: Session, user: User) -> None: if not is_question_manager(db, user): raise HTTPException(403, "Folders are for educators — moderator access or a grant") def _json(db: Session, folder: QuestionFolder, owners: dict[int, User], mine: bool) -> dict: return { "id": folder.id, "name": folder.name, "description": folder.description, "question_count": db.query(func.count(QuestionFolderQuestion.id)).filter( QuestionFolderQuestion.folder_id == folder.id).scalar() or 0, "owner_id": folder.user_id, "owner_name": getattr(owners.get(folder.user_id), "name", None), # Whether this caller may change what is in it, so the page does not # have to reproduce the rule and get it subtly different. "can_edit": mine, "created_at": folder.created_at, "updated_at": folder.updated_at, } def _readable(db: Session, user: User) -> list[QuestionFolder]: """Folders this person may see: their own, plus any they were granted.""" query = db.query(QuestionFolder) if not user.is_moderator: granted = granted_folder_ids(db, user) visible = QuestionFolder.user_id == user.id if granted: visible = or_(visible, QuestionFolder.id.in_(granted)) query = query.filter(visible) return query.order_by(QuestionFolder.name).all() def _may_edit(user: User, folder: QuestionFolder) -> bool: return bool(user.is_moderator) or folder.user_id == user.id def _folder(db: Session, user: User, folder_id: int, *, write: bool) -> QuestionFolder: folder = db.get(QuestionFolder, folder_id) if not folder: raise HTTPException(404, "Folder not found") if write: if not _may_edit(user, folder): raise HTTPException(403, "Only the folder's owner may change what is in it") return folder if _may_edit(user, folder) or folder.id in granted_folder_ids(db, user): return folder raise HTTPException(404, "Folder not found") @router.get("/") def list_folders(db: Session = Depends(get_db), user: User = Depends(get_current_user)): _require_manager(db, user) folders = _readable(db, user) owners = {u.id: u for u in db.query(User).filter( User.id.in_({f.user_id for f in folders if f.user_id}))} if folders else {} return [_json(db, folder, owners, _may_edit(user, folder)) for folder in folders] @router.post("/", status_code=201) def create_folder(data: FolderWrite, db: Session = Depends(get_db), user: User = Depends(get_current_user)): _require_manager(db, user) folder = QuestionFolder(name=data.name, description=(data.description or "").strip() or None, user_id=user.id) db.add(folder) db.commit() db.refresh(folder) return _json(db, folder, {user.id: user}, True) @router.patch("/{folder_id}") def rename_folder(folder_id: int, data: FolderWrite, db: Session = Depends(get_db), user: User = Depends(get_current_user)): _require_manager(db, user) folder = _folder(db, user, folder_id, write=True) folder.name = data.name folder.description = (data.description or "").strip() or None db.commit() return _json(db, folder, {}, True) @router.delete("/{folder_id}", status_code=204) def delete_folder(folder_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): """Delete a folder, and with it every grant that pointed at it. The grants go by the foreign key's cascade rather than being left dangling: a grant naming nothing grants nothing, so an orphan row would quietly become a grant that covers no questions and reads on the access page as though the person still holds something. """ _require_manager(db, user) folder = _folder(db, user, folder_id, write=True) db.query(CategoryGrant).filter(CategoryGrant.folder_id == folder.id).delete( synchronize_session=False) db.delete(folder) db.commit() @router.get("/{folder_id}/questions") def folder_questions(folder_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): _require_manager(db, user) folder = _folder(db, user, folder_id, write=False) rows = (db.query(Question) .join(QuestionFolderQuestion, QuestionFolderQuestion.question_id == Question.id) .filter(QuestionFolderQuestion.folder_id == folder.id) .order_by(QuestionFolderQuestion.id) .all()) return [{"id": q.id, "question_text": q.question_text, "question_category_id": q.question_category_id} for q in rows] @router.post("/{folder_id}/questions") def add_questions(folder_id: int, data: FolderQuestions, db: Session = Depends(get_db), user: User = Depends(get_current_user)): """Put questions in. Only ones this educator already manages. Without that check a non-moderator could file a question they cannot reach into a folder and then be handed a grant over it — access laundered through a list they wrote themselves. """ _require_manager(db, user) folder = _folder(db, user, folder_id, write=True) wanted = list(dict.fromkeys(data.question_ids)) existing = {row[0] for row in db.query(Question.id).filter(Question.id.in_(wanted)).all()} missing = [qid for qid in wanted if qid not in existing] if missing: raise HTTPException(404, f"No such question: {missing[0]}") assert_user_can_manage(db, user, wanted) already = {row[0] for row in db.query(QuestionFolderQuestion.question_id).filter( QuestionFolderQuestion.folder_id == folder.id, QuestionFolderQuestion.question_id.in_(wanted)).all()} added = 0 for question_id in wanted: if question_id in already: continue db.add(QuestionFolderQuestion(folder_id=folder.id, question_id=question_id, added_by=user.id)) added += 1 db.commit() return {"added": added, "already_there": len(already)} @router.delete("/{folder_id}/questions/{question_id}", status_code=204) def remove_question(folder_id: int, question_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): _require_manager(db, user) folder = _folder(db, user, folder_id, write=True) db.query(QuestionFolderQuestion).filter_by( folder_id=folder.id, question_id=question_id).delete(synchronize_session=False) db.commit()