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