pdf-quiz-generator/backend/app/utils/quiz_access.py
Daniel b6cfcaa1e9
Some checks failed
Tests / backend (push) Failing after 5s
Tests / frontend (push) Successful in 32s
Tests / e2e (push) Failing after 26s
feat: the bank belongs to a role, not to a person
571 categories, 21 uploaded documents, 14 articles, 8 card decks, 30
shared tests and 2 questions carried somebody's name — mostly
daniel@danvics.com, which is not even the working administrator any
more. So "who may edit this" partly depended on who happened to create
it, and handing the site to somebody else would have meant rewriting
every one of those rows.

Migration q6a7b8c9d0e1 empties those owner columns and makes them
nullable, because ownerless is now a legitimate state and a NOT NULL
owner is exactly what forced a name onto every row. Nothing is deleted
and nothing moves. What keeps its owner, deliberately: attempts, notes,
favourites, collections, folders, study-plan progress, and the quizzes
that are somebody's own sittings rather than shared bank tests.
study_plans needed nothing — it never had an owner column.

Then the code, so it cannot grow back. Authorship is no longer a way in
anywhere: may_edit_question and can_edit_article ask the role and the
grants and nothing else; the article draft, status and delete paths lost
their "or you wrote it" arm; decks are the bank's, so an educator
reaches any of them and a learner reaches the shared ones; documents are
the corpus, so they are editors-only rather than "mine"; and every
creation path writes user_id NULL. The bank listing's "mine" facet went
with it — it counted nothing and could only ever count nothing.

Verified against production as a real learner account: every bank write
403s, admin settings 403, documents empty. As an admin, everything
opens.

Also: a category grant no longer offers Editorial in the menu. It offers
Questions and Images, which is what a grant covers; Editorial is the
whole library's review queue and its route is moderator-only, so the
entry was a door that answered "Not yours to open".

Six tests changed rather than deleted — they asserted the old model, and
each now asserts the new one: writing an article does not make it yours,
writing a question does not make it yours, an answer image is not opened
by authorship, the tutor is not opened by authorship.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 13:26:25 +02:00

104 lines
4.8 KiB
Python

"""One quiz visibility rule for web, attempts and mobile."""
from fastapi import HTTPException
from sqlalchemy import or_, select
from app.models.attempt import QuizAttempt
from app.utils.quiz_questions import question_in_quiz
from app.models.question import Question
from app.models.quiz import Quiz
from app.models.quiz_question_link import QuizQuestionLink
from app.services.quiz_builder import shareable_question_predicate, bank_question_predicate
def quiz_shareable_predicate(user=None):
allowed = bank_question_predicate(user) if user is not None else shareable_question_predicate()
return ~select(QuizQuestionLink.quiz_id).join(Question, Question.id == QuizQuestionLink.question_id).where(
QuizQuestionLink.quiz_id == Quiz.id, allowed.is_not(True),
).exists()
def general_quiz_visibility(user):
privileged = (Quiz.user_id == user.id) & quiz_shareable_predicate(user)
if user.is_moderator:
privileged = True
return (Quiz.deleted_at.is_(None) & or_(
privileged,
(or_(Quiz.is_published == 1, Quiz.is_shared == 1) & quiz_shareable_predicate()),
))
def can_access_quiz(db, quiz, user):
if not quiz or quiz.deleted_at is not None:
return False
return db.query(Quiz.id).filter(Quiz.id == quiz.id, general_quiz_visibility(user)).first() is not None
def require_quiz_access(db, quiz, user, review=False):
if not can_access_quiz(db, quiz, user):
raise HTTPException(403, "This quiz is private or no longer available")
def set_quiz_shared(db, quiz, user, shared):
if quiz.user_id != user.id and not user.is_moderator:
raise HTTPException(403, "Only the owner or a moderator can change sharing")
if shared and not db.query(Quiz.id).filter(Quiz.id == quiz.id, quiz_shareable_predicate()).first():
raise HTTPException(400, "This test contains private questions")
quiz.is_shared = int(shared)
# Explicit revocation must also revoke legacy publication.
if not shared:
quiz.is_published = 0
db.commit()
return {"id": quiz.id, "is_shared": quiz.is_shared, "is_published": quiz.is_published}
def may_edit_question(db, question, user) -> bool:
"""Whether this person writes this question, rather than sits it.
Two ways in: the admin role, or an editorial grant that covers where the
question is filed. It is what separates reading an answer because it is
your job from reading it because you found the URL.
Authorship used to be a third way. It is not one any more: nothing in the
bank belongs to a person, so "I made this" is not a claim the bank can
check or would honour if it could. Rights come from the role and from
grants, and only from those — which is the point of having a grant system
at all.
"""
if user.is_moderator:
return True
from app.utils.category_grants import question_scope_predicate
predicate = question_scope_predicate(db, user)
if predicate is None:
return True
return db.query(Question.id).filter(Question.id == question.id, predicate).first() is not None
def require_question_access(db, question, user, attempt_id=None, review=False):
"""Authorize tutor/answer content or a stem.
`review=True` means answer-side content: the correct option, the
explanation, the picture of the explanation, and the tutor — which is given
the answer and told it may explain it, so it is answer-side too.
"""
if question is None:
raise HTTPException(404, "Question not found")
if attempt_id is not None:
attempt = db.query(QuizAttempt).filter_by(id=attempt_id, user_id=user.id).first()
if (not attempt or not question_in_quiz(db, attempt.quiz_id, question.id)
or (attempt.selected_question_ids is not None and question.id not in attempt.selected_question_ids)):
raise HTTPException(403, "Question is not available in this attempt")
require_quiz_access(db, attempt.quiz, user, review=review)
if review and attempt.mode != "study" and attempt.completed_at is None:
raise HTTPException(403, "Complete the attempt before reviewing")
return
if user.is_moderator:
return
if db.query(Question.id).filter(Question.id == question.id, bank_question_predicate(user)).first():
# In the bank, so the stem is theirs to read. The answer beside it is
# not: without an attempt, the only people who see it are the people
# who write it. Being able to reach a question was never the same as
# having earned its answer, and the tutor is an answer read aloud.
if not review or may_edit_question(db, question, user):
return
raise HTTPException(403, "Sit this question to see its answer")
raise HTTPException(403, "Question is private or requires an authorized study/review attempt")