pdf-quiz-generator/backend/app/models/user_note.py
Daniel 532d613393 feat: question folders, per-section notes, and two feedback paths
Four things that share a spine, so they arrive together.

**Folders.** A hand-picked set of questions, and the fourth thing a grant can
name beside exam, discipline and category. Deliberately not `user_collections`
with a sharing flag: a library is a consequence of access — you save what you
can already see — while a folder is a source of it, and one table holding
thousands of private lists beside a handful that confer permission is one
mistake away from a leak. Built from the question manager, granted on /access.
Membership stays with the owner and moderators so a grantee cannot widen their
own reach, and deleting a folder takes its grants with it.

Two live constraints had to be rewritten to accept it: `ck_grant_has_a_dimension`
and `uq_grant_dimensions` both predate `folder_id`, so a folder-only grant
failed the check and two folder grants collided on the unique index.

**Per-question feedback.** The learner's half already existed. What was wrong
was who could read it: any grant at all let an educator list and delete reports
about the whole bank. Reports are now scoped by `question_scope_predicate`, the
same predicate that decides which questions that educator can see, and a reply
thread makes the report a conversation the learner can follow rather than a
form that swallows what they said.

**Per-section notes and article feedback.** Two tables on purpose:
`article_section_notes` is private to whoever wrote it, `article_feedback` goes
to whoever maintains the article. Both point at the section id inside
`articles.sections` rather than at `article_section_index`, whose rows are
dropped on unpublish — a cascade from there would delete a learner's writing
because an educator took an article down for an afternoon. A rename keeps a
note attached; a deleted section leaves it marked orphaned under the heading it
was written on, for its writer alone to remove.

The header's feedback badge covers both, because questions and reading are the
same job to whoever is doing it.

Migration i9f0a1b2c3d4. 556 backend and 572 frontend tests pass.

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

70 lines
3.3 KiB
Python

from datetime import datetime
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import relationship
from app.database import Base
class UserNote(Base):
__tablename__ = "user_notes"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=False, index=True)
content = Column(Text, nullable=False, default="")
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
user = relationship("User", back_populates="note")
class QuestionNote(Base):
"""A learner's own note on one question.
Separate from the single global note, which was one page for everything and
so was never about the question in front of you.
"""
__tablename__ = "question_notes"
__table_args__ = (UniqueConstraint("user_id", "question_id", name="uq_question_note"),)
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
question_id = Column(Integer, ForeignKey("questions.id", ondelete="CASCADE"), nullable=False, index=True)
content = Column(Text, nullable=False, default="")
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class ArticleSectionNote(Base):
"""A learner's own note on one section of an article.
The same thing as `QuestionNote` pointed at different reading: one row per
person per section, empty means deleted, and nobody but the writer ever
reads it. Feedback to the article's maintainer is a separate table, because
a private note that could be read by an educator is not a private note.
`section_id` is a key inside `articles.sections`, deliberately not a foreign
key onto `article_section_index`: those rows are dropped when an article is
unpublished, and a cascade from them would delete a learner's writing
because an educator took an article down for an afternoon.
A section can be renamed (the id survives, so the note stays attached) or
deleted (the id goes, and the note has nowhere to render). It is kept
either way and reported as orphaned, with the heading it was written under
recorded here — losing somebody's writing quietly is worse than showing
them a note whose section has gone.
"""
__tablename__ = "article_section_notes"
__table_args__ = (UniqueConstraint("user_id", "article_id", "section_id",
name="uq_article_section_note"),)
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
article_id = Column(Integer, ForeignKey("articles.id", ondelete="CASCADE"), nullable=False, index=True)
section_id = Column(String(64), nullable=False)
section_title = Column(String(300), nullable=True)
content = Column(Text, nullable=False, default="")
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)