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