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
63 lines
3.1 KiB
Python
63 lines
3.1 KiB
Python
from datetime import datetime
|
|
|
|
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text
|
|
|
|
from app.database import Base
|
|
|
|
|
|
class QuestionFeedback(Base):
|
|
"""A learner telling an educator something is wrong with a question.
|
|
|
|
It replaces the comment thread that used to sit under every question. A
|
|
discussion is public and needs moderating; this is a private report that
|
|
someone is expected to act on, which is what people were using comments for
|
|
anyway. It carries the question id because that is what an educator needs
|
|
to find the thing being reported.
|
|
"""
|
|
|
|
__tablename__ = "question_feedback"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
question_id = Column(Integer, ForeignKey("questions.id", ondelete="CASCADE"), nullable=False, index=True)
|
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
|
message = Column(Text, nullable=False)
|
|
#: open | resolved. Kept rather than deleted on resolve, so a question with
|
|
#: a history of the same complaint is visibly that.
|
|
status = Column(String(20), nullable=False, default="open", index=True)
|
|
reply = Column(Text, nullable=True)
|
|
replied_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
|
replied_at = Column(DateTime, nullable=True)
|
|
created_at = Column(DateTime, default=datetime.utcnow, index=True)
|
|
|
|
|
|
class ArticleFeedback(Base):
|
|
"""A reader telling whoever maintains an article that something is wrong.
|
|
|
|
The same shape as a question report, and deliberately so: an educator works
|
|
through one queue, not two that behave differently.
|
|
|
|
`section_id` is a key inside `articles.sections`, not a foreign key — the
|
|
sections live in a JSON column and are only projected into
|
|
`article_section_index` while the article is published. A foreign key onto
|
|
that projection would delete every outstanding report the moment somebody
|
|
unpublished an article to fix it, which is precisely when the reports
|
|
matter. Null means the report is about the article as a whole.
|
|
"""
|
|
|
|
__tablename__ = "article_feedback"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
article_id = Column(Integer, ForeignKey("articles.id", ondelete="CASCADE"), nullable=False, index=True)
|
|
section_id = Column(String(64), nullable=True, index=True)
|
|
#: What the section was called when the report was written. A section can be
|
|
#: renamed or deleted between the report and the reply, and "the report is
|
|
#: about a section that no longer exists" is unactionable on its own.
|
|
section_title = Column(String(300), nullable=True)
|
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
|
message = Column(Text, nullable=False)
|
|
#: open | resolved, as for a question report.
|
|
status = Column(String(20), nullable=False, default="open", index=True)
|
|
reply = Column(Text, nullable=True)
|
|
replied_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
|
replied_at = Column(DateTime, nullable=True)
|
|
created_at = Column(DateTime, default=datetime.utcnow, index=True)
|