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