pdf-quiz-generator/backend/app/routers/folders.py
Daniel 532d613393 feat: question folders, per-section notes, and two feedback paths
Four things that share a spine, so they arrive together.

**Folders.** A hand-picked set of questions, and the fourth thing a grant can
name beside exam, discipline and category. Deliberately not `user_collections`
with a sharing flag: a library is a consequence of access — you save what you
can already see — while a folder is a source of it, and one table holding
thousands of private lists beside a handful that confer permission is one
mistake away from a leak. Built from the question manager, granted on /access.
Membership stays with the owner and moderators so a grantee cannot widen their
own reach, and deleting a folder takes its grants with it.

Two live constraints had to be rewritten to accept it: `ck_grant_has_a_dimension`
and `uq_grant_dimensions` both predate `folder_id`, so a folder-only grant
failed the check and two folder grants collided on the unique index.

**Per-question feedback.** The learner's half already existed. What was wrong
was who could read it: any grant at all let an educator list and delete reports
about the whole bank. Reports are now scoped by `question_scope_predicate`, the
same predicate that decides which questions that educator can see, and a reply
thread makes the report a conversation the learner can follow rather than a
form that swallows what they said.

**Per-section notes and article feedback.** Two tables on purpose:
`article_section_notes` is private to whoever wrote it, `article_feedback` goes
to whoever maintains the article. Both point at the section id inside
`articles.sections` rather than at `article_section_index`, whose rows are
dropped on unpublish — a cascade from there would delete a learner's writing
because an educator took an article down for an afternoon. A rename keeps a
note attached; a deleted section leaves it marked orphaned under the heading it
was written on, for its writer alone to remove.

The header's feedback badge covers both, because questions and reading are the
same job to whoever is doing it.

Migration i9f0a1b2c3d4. 556 backend and 572 frontend tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 18:37:43 +02:00

209 lines
8.3 KiB
Python

"""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()