pdf-quiz-generator/backend/app/models/question.py
Daniel 5d59e00144 refactor: remove per-question sharing
`Question.is_shared` defaulted to 1 and was only ever set by a route nothing
called, so in practice it divided the bank into "everything" and "everything,
plus your own private ones" — a distinction that cost every recommendation
denominator a join and never changed an answer. Who may reach the bank is the
site's own access rules; who may manage a question is the category grant tree.

So the two predicates the whole bank was built on are now the same thing, and
say what they actually mean: a question is out of reach if it has been deleted
or belongs to a course. Nothing else. The column is dropped, the route that set
it is gone, the bulk "share" action with it, and the Private tile and pill go
from the question manager.

The tests that turned on it have been rewritten rather than deleted, because
the rule they were really about survives: revoking a question still revokes
every session carrying it — by deleting it, which is the only revocation left.
Several others named a category holding exactly two reachable questions and
then answered two particular ids; that category holds four now, so they name
the pair instead. A session's own sharing flag is untouched — that is a
different thing, and it is still how a session is handed to somebody.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 08:42:51 +02:00

64 lines
3.5 KiB
Python

from datetime import datetime
from pgvector.sqlalchemy import Vector
from sqlalchemy import Column, DateTime, Integer, String, Text, JSON, ForeignKey
from sqlalchemy.orm import relationship, deferred
from app.config import settings
from app.database import Base
from app.models.question_category import QuestionCategory # noqa — ensures mapper resolves
from app.models.quiz_question_link import QuizQuestionLink # noqa
class Question(Base):
__tablename__ = "questions"
id = Column(Integer, primary_key=True, index=True)
# source_quiz_id: which quiz this question was originally extracted for (informational).
# Content membership is tracked via quiz_question_links junction table.
source_quiz_id = Column("quiz_id", Integer, ForeignKey("quizzes.id", ondelete="SET NULL"), nullable=True)
question_category_id = Column(Integer, ForeignKey("question_categories.id", ondelete="SET NULL"), nullable=True)
question_text = Column(Text, nullable=False)
question_type = Column(String, nullable=False) # mcq, true_false, fill_blank
options = Column(JSON, nullable=True) # list of strings for mcq
correct_answer = Column(String, nullable=False)
explanation = Column(Text, nullable=True)
page_reference = Column(Integer, nullable=True)
image_path = Column(String, nullable=True)
explanation_image_path = Column(String, nullable=True)
option_explanations = Column(JSON, nullable=True) # {option_text: explanation}
key_points = Column(JSON, nullable=True) # [{"text", "article_id"?, "article_section_id"?}] smart links
# One sentence an attending would say at the bedside. Read before the answer
# is known, so it must point at the thinking without giving the answer away.
attending_tip = Column(Text, nullable=True)
difficulty = Column(String(10), nullable=True) # easy | medium | hard
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
embedding = deferred(Column(Vector(settings.EMBEDDING_DIMENSIONS), nullable=True)) # semantic search vector — deferred: not loaded in standard queries
# Which model produced `embedding`. Vectors from different models are not
# comparable, so a model change must be detectable rather than silent.
embedding_model = Column(String(120), nullable=True, index=True)
embedded_at = Column(DateTime, nullable=True)
# Deleting is hiding, not erasing. Ids come from a sequence and are never
# reissued, and fourteen tables point at this one — attempts, quiz
# membership, exam membership, media, notes, feedback. A hard delete takes
# all of that with it and nothing can put it back, so the row stays and
# this column says it is gone.
deleted_at = Column(DateTime, nullable=True, index=True)
question_category = relationship("QuestionCategory", back_populates="questions",
foreign_keys=[question_category_id])
class QuestionVersion(Base):
"""A snapshot of a question as it was before an edit.
Only the last MAX_VERSIONS are kept: the point is undoing a recent mistake,
not an audit trail, and full question bodies add up.
"""
__tablename__ = "question_versions"
id = Column(Integer, primary_key=True, index=True)
question_id = Column(Integer, ForeignKey("questions.id", ondelete="CASCADE"), nullable=False, index=True)
snapshot = Column(JSON, nullable=False)
edited_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)