pdf-quiz-generator/backend/app/models/quiz.py
Daniel b6cfcaa1e9
Some checks failed
Tests / backend (push) Failing after 5s
Tests / frontend (push) Successful in 32s
Tests / e2e (push) Failing after 26s
feat: the bank belongs to a role, not to a person
571 categories, 21 uploaded documents, 14 articles, 8 card decks, 30
shared tests and 2 questions carried somebody's name — mostly
daniel@danvics.com, which is not even the working administrator any
more. So "who may edit this" partly depended on who happened to create
it, and handing the site to somebody else would have meant rewriting
every one of those rows.

Migration q6a7b8c9d0e1 empties those owner columns and makes them
nullable, because ownerless is now a legitimate state and a NOT NULL
owner is exactly what forced a name onto every row. Nothing is deleted
and nothing moves. What keeps its owner, deliberately: attempts, notes,
favourites, collections, folders, study-plan progress, and the quizzes
that are somebody's own sittings rather than shared bank tests.
study_plans needed nothing — it never had an owner column.

Then the code, so it cannot grow back. Authorship is no longer a way in
anywhere: may_edit_question and can_edit_article ask the role and the
grants and nothing else; the article draft, status and delete paths lost
their "or you wrote it" arm; decks are the bank's, so an educator
reaches any of them and a learner reaches the shared ones; documents are
the corpus, so they are editors-only rather than "mine"; and every
creation path writes user_id NULL. The bank listing's "mine" facet went
with it — it counted nothing and could only ever count nothing.

Verified against production as a real learner account: every bank write
403s, admin settings 403, documents empty. As an admin, everything
opens.

Also: a category grant no longer offers Editorial in the menu. It offers
Questions and Images, which is what a grant covers; Editorial is the
whole library's review queue and its route is moderator-only, so the
entry was a door that answered "Not yours to open".

Six tests changed rather than deleted — they asserted the old model, and
each now asserts the new one: writing an article does not make it yours,
writing a question does not make it yours, an answer image is not opened
by authorship, the tutor is not opened by authorship.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 13:26:25 +02:00

59 lines
3.3 KiB
Python

from datetime import datetime
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text
from sqlalchemy.orm import relationship
from app.database import Base
from app.models.quiz_category import QuizCategory # noqa — ensures mapper resolves "QuizCategory"
from app.models.quiz_question_link import QuizQuestionLink # noqa
#: `Quiz.origin` for a quiz that backs a study-plan block rather than standing
#: on its own. Kept here because both the study-plan builder that sets it and
#: the session list that filters on it need the same spelling.
PLAN_ORIGIN = "plan"
class Quiz(Base):
__tablename__ = "quizzes"
id = Column(Integer, primary_key=True, index=True)
section_id = Column(Integer, ForeignKey("sections.id", ondelete="SET NULL"), nullable=True)
# Cascades: deleting an account is documented as removing what it made.
# Declared here because the database has always done it, and a model that
# says nothing is a model that will be believed.
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True)
category_id = Column(Integer, ForeignKey("quiz_categories.id", ondelete="SET NULL"), nullable=True)
title = Column(String, nullable=False)
questions_count = Column(Integer, default=0)
time_limit_minutes = Column(Integer, nullable=True) # null = no limit
mode = Column(String, default="timed") # timed, learning
skipped_questions = Column(Text, nullable=True) # JSON list of skipped question texts
created_at = Column(DateTime, default=datetime.utcnow)
deleted_at = Column(DateTime, nullable=True) # soft delete — null = active
is_published = Column(Integer, default=1) # 1 = visible to users, 0 = hidden (admin only)
is_shared = Column(Integer, default=0) # 0=private, 1=shared (visible to other users)
# A repetition is practice, not a new measurement. You have already seen
# these questions and their answers, so getting them right the second time
# says nothing about whether you knew them — it cannot raise a percentage
# that is meant to mean "how much of this do you know".
is_repetition = Column(Integer, default=0, index=True)
max_attempts = Column(Integer, nullable=True) # null = unlimited
questions_per_attempt = Column(Integer, nullable=True) # null = all; set = random subset from pool
share_token = Column(String(64), unique=True, nullable=True) # public /share/{token} link when set
# bank | upload | ai | sample | plan.
# "plan" marks a quiz that exists to back a study-plan block. It is still a
# real quiz — it just is not something the learner picks off a list.
origin = Column(String(20), default="bank")
section = relationship("Section", back_populates="quizzes")
user = relationship("User", back_populates="quizzes")
category = relationship("QuizCategory", back_populates="quizzes", foreign_keys=[category_id])
questions = relationship(
"Question",
secondary="quiz_question_links",
primaryjoin="Quiz.id == foreign(QuizQuestionLink.quiz_id)",
secondaryjoin="Question.id == foreign(QuizQuestionLink.question_id)",
order_by="QuizQuestionLink.position",
viewonly=True, # mutations handled explicitly via QuizQuestionLink
)
attempts = relationship("QuizAttempt", back_populates="quiz", cascade="all, delete-orphan")