pdf-quiz-generator/backend/app/utils/quiz_access.py
Daniel f696b99569 fix: close custom-test review gaps and verify grading
Handle ownerless question revocation, legacy hide sharing, private deletion, selected-set grading including skips, UI validation/reparent/delete safeguards and offline-safe hierarchy migration. Verified 14 backend tests in deployed image, 13 frontend tests/build and real disposable PostgreSQL migration round-trip. Related tutor/image privacy work remains before deployment.
2026-09-07 02:07:58 +02:00

66 lines
2.9 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.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}