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
90 lines
4.6 KiB
Python
90 lines
4.6 KiB
Python
from datetime import datetime
|
|
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, UniqueConstraint
|
|
from sqlalchemy.orm import relationship
|
|
from app.database import Base
|
|
from app.models.embeddable import Embeddable
|
|
|
|
class FlashcardDeck(Base):
|
|
__tablename__ = "flashcard_decks"
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
title = Column(String, nullable=False)
|
|
section_id = Column(Integer, ForeignKey("sections.id", ondelete="SET NULL"), nullable=True)
|
|
# Same tree as questions and articles, so one category filters all three.
|
|
category_id = Column(Integer, ForeignKey("question_categories.id", ondelete="SET NULL"), nullable=True, index=True)
|
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True)
|
|
card_count = Column(Integer, default=0)
|
|
is_shared = Column(Integer, default=0) # 0 = private, 1 = shared
|
|
created_at = Column(DateTime, default=datetime.utcnow)
|
|
deleted_at = Column(DateTime, nullable=True)
|
|
cards = relationship("Flashcard", back_populates="deck", cascade="all, delete-orphan")
|
|
ratings = relationship("FlashcardDeckRating", back_populates="deck", cascade="all, delete-orphan")
|
|
user = relationship("User")
|
|
|
|
|
|
class FlashcardDeckRating(Base):
|
|
__tablename__ = "flashcard_deck_ratings"
|
|
__table_args__ = (UniqueConstraint("user_id", "deck_id", name="uq_deck_rating_user"),)
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
deck_id = Column(Integer, ForeignKey("flashcard_decks.id", ondelete="CASCADE"), nullable=False)
|
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
|
rating = Column(Integer, nullable=False) # 1-5
|
|
created_at = Column(DateTime, default=datetime.utcnow)
|
|
deck = relationship("FlashcardDeck", back_populates="ratings")
|
|
|
|
class Flashcard(Base, Embeddable):
|
|
__tablename__ = "flashcards"
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
deck_id = Column(Integer, ForeignKey("flashcard_decks.id", ondelete="CASCADE"), nullable=False)
|
|
front = Column(Text, nullable=False)
|
|
back = Column(Text, nullable=False)
|
|
page_reference = Column(Integer, nullable=True)
|
|
image_path = Column(String, nullable=True)
|
|
created_at = Column(DateTime, default=datetime.utcnow)
|
|
deck = relationship("FlashcardDeck", back_populates="cards")
|
|
|
|
|
|
class FlashcardReview(Base):
|
|
"""One verdict on one card, kept so the deck can come back at the right time.
|
|
|
|
Cards had no memory at all: "known" and "to review" were React state that
|
|
vanished on reload, so a deck of two hundred was two hundred cards every
|
|
time and the only spacing was whichever ones you happened to remember to
|
|
skip.
|
|
|
|
A log rather than a row per card. What the scheduler needs is the *latest*
|
|
verdict and how long ago it was, which a log gives; and keeping the history
|
|
means a card answered wrongly three times in a row can eventually be
|
|
treated differently from one missed once, without a migration to add the
|
|
column that would have recorded it.
|
|
"""
|
|
|
|
__tablename__ = "flashcard_reviews"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
|
flashcard_id = Column(Integer, ForeignKey("flashcards.id", ondelete="CASCADE"),
|
|
nullable=False, index=True)
|
|
#: "known" or "again". Two outcomes, because a self-graded scale of four
|
|
#: asks a learner to rate their own recall on a scale they have not
|
|
#: calibrated, and the extra resolution is noise.
|
|
outcome = Column(String(10), nullable=False)
|
|
created_at = Column(DateTime, default=datetime.utcnow, index=True)
|
|
|
|
|
|
class FlashcardQuestionLink(Base):
|
|
__tablename__ = "flashcard_question_links"
|
|
__table_args__ = (UniqueConstraint("flashcard_id", "question_id", name="uq_card_question"),)
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
flashcard_id = Column(Integer, ForeignKey("flashcards.id", ondelete="CASCADE"), nullable=False)
|
|
question_id = Column(Integer, ForeignKey("questions.id", ondelete="CASCADE"), nullable=False)
|
|
|
|
|
|
class FlashcardArticleLink(Base):
|
|
__tablename__ = "flashcard_article_links"
|
|
__table_args__ = (UniqueConstraint("flashcard_id", "article_id", "article_section_id", name="uq_card_article_section"),)
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
flashcard_id = Column(Integer, ForeignKey("flashcards.id", ondelete="CASCADE"), nullable=False)
|
|
article_id = Column(Integer, ForeignKey("articles.id", ondelete="CASCADE"), nullable=False)
|
|
article_section_id = Column(String(64), nullable=True)
|