pdf-quiz-generator/backend/app/utils/quiz_access.py
Daniel 1ce3eec7cb feat: authorize tutor context and protect uploaded media
Tutor questions require owned selected attempts; similarity context filters eligibility before ranking. Uploads move to a permission-aware boundary with reference ACLs, canonical legacy aliases, pre-mutation attachment checks and card-aware moderator rules. Nginx stops caching media and supplies native byte ranges. Verified 37 deployed-image backend tests, 69 frontend tests/build, real pgvector/Nginx/browser checks, and two independent reviews.
2026-09-07 15:04:00 +02:00

93 lines
4.3 KiB
Python

"""One general-quiz visibility rule for web, attempts and mobile.
Course access is deliberately separate: publication/sharing never grants enrollment.
"""
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.course import Course, CourseEnrollment
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.course_id.is_(None) & 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
if quiz.course_id is None:
return db.query(Quiz.id).filter(Quiz.id == quiz.id, general_quiz_visibility(user)).first() is not None
course = db.query(Course).filter(Course.id == quiz.course_id).first()
if not course:
return False
if user.is_moderator or course.user_id == user.id:
return True
return db.query(CourseEnrollment.id).filter(
CourseEnrollment.course_id == course.id, CourseEnrollment.user_id == user.id,
).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")
if review and quiz.course_id is not None and quiz.allow_review != 1 and not user.is_moderator and quiz.user_id != user.id:
raise HTTPException(403, "Review is not allowed for this course quiz")
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 quiz.course_id is not None:
raise HTTPException(400, "Course quizzes cannot be shared in the general bank")
if shared and not db.query(Quiz.id).filter(Quiz.id == quiz.id, quiz_shareable_predicate()).first():
raise HTTPException(400, "This test contains private or course-only 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 require_question_access(db, question, user, attempt_id=None, review=False):
"""Authorize tutor/answer content or a stem, never trusting a course sharing flag."""
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():
return
quiz = db.get(Quiz, question.source_quiz_id) if question.source_quiz_id else None
if quiz and quiz.course_id is not None:
require_quiz_access(db, quiz, user, review=review)
if not review or quiz.user_id == user.id:
return
raise HTTPException(403, "Question is private or requires an authorized study/review attempt")