pdf-quiz-generator/backend/app/models/question.py
Daniel 817cff569d feat: question version history with restore; TODO list
Editing a question now snapshots its previous state. The last 5 are kept — the
value is undoing a recent mistake, not an audit trail, and an uncapped history of
full question bodies grows without bound (migration a9b0c1d2e3f4).

A restore snapshots the current state first, so the restore is itself undoable.
History is gated by the same per-category grant that gates editing, so it cannot
be read by someone who could not have made the edit. The question editor shows
the versions with their dates and a Restore action.

Also added docs/TODO.md tracking everything requested and not yet delivered:
AI Mode and its citation contract, global search, study-plan editing and
articles-in-blocks, admin settings revamp, image libraries and question folders,
media management, nested article sections with references and per-section notes
and feedback, per-question notes and feedback in the runner, tutorial mode, the
per-question performance table, the Overview dashboard, systems subsystems, and
dropping "Pediatrics" as a discipline.

Tests: 6 new backend (snapshot on edit, cap at five newest-first, restore,
restore is undoable, refused without edit rights, unknown version). Full suites
green: 119 backend, 136 frontend, build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpfzbZ1QTLMeVYxM2kyq8m
2026-09-10 02:27:44 +02:00

56 lines
3 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
difficulty = Column(String(10), nullable=True) # easy | medium | hard
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
is_shared = Column(Integer, default=1) # 1 = visible in bank, 0 = private (only owner sees it)
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)
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)