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.
93 lines
4.3 KiB
Python
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")
|