Thirteen plans were seeded with an API to serve them and nothing that called it, so the whole feature existed only in the database. Two pages and the editing endpoints it was missing. /study-plans lists the plans with progress stated in blocks — "3 of 6 blocks" is something you can act on, where "50%" only tells you how you feel about it. /study-plans/:id is one plan: each block shows Articles, then Sessions, in that order, because that is the order the block is meant to be done in. Reading is now part of a block (migration f4a5b6c7d8e9). "Mark as read" is the learner's own claim and reversible — someone who ticks the wrong row should be able to fix it without an educator, and progress nobody can correct stops being trusted and then stops being used. It is a separate table from `article_views` on purpose: opening an article is not the same claim as having finished with it. A draft article attached to a block is listed for the educator who can open it and left out for everyone else, rather than offered as a dead link. Editing is inline on the learner's own page rather than a separate builder, so the thing being changed and the thing a learner sees are the same object. Moderators create (as a draft — an empty plan is not something to put in front of anyone), rename, publish, delete; add, rename, reorder and remove blocks; move questions between blocks of one plan; attach reading found by searching rather than by id. Two places where the obvious implementation leaves the data wrong, both tested: deleting a block out of the middle shuffles the survivors down, or the next insert collides with a position nothing occupies; and reordering parks every row outside the range before writing the real positions, because (plan_id, position) is unique and the first move would otherwise collide with a position still held. A partial order is refused rather than half-applied. 166 backend, 188 frontend green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XeFQJXJTfHKTfbfsdxv57Z
89 lines
3.8 KiB
Python
89 lines
3.8 KiB
Python
from datetime import datetime
|
|
|
|
from sqlalchemy import JSON, Column, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
|
|
from sqlalchemy.orm import relationship
|
|
|
|
from app.database import Base
|
|
|
|
|
|
class StudyPlan(Base):
|
|
"""An ordered set of question blocks a learner works through."""
|
|
|
|
__tablename__ = "study_plans"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
slug = Column(String(120), unique=True, nullable=False, index=True)
|
|
name = Column(String(200), nullable=False)
|
|
description = Column(Text, nullable=True)
|
|
exam_id = Column(Integer, ForeignKey("exams.id", ondelete="SET NULL"), nullable=True)
|
|
kind = Column(String(20), default="set") # set | mixed
|
|
sort_order = Column(Integer, default=100)
|
|
is_published = Column(Integer, default=1)
|
|
created_at = Column(DateTime, default=datetime.utcnow)
|
|
|
|
blocks = relationship("StudyPlanBlock", back_populates="plan",
|
|
cascade="all, delete-orphan", order_by="StudyPlanBlock.position")
|
|
|
|
|
|
class StudyPlanBlock(Base):
|
|
"""One numbered block. Its question ids are fixed, not a live filter.
|
|
|
|
A plan you are part-way through must not reshuffle between visits, so the
|
|
membership is snapshotted when the plan is built.
|
|
"""
|
|
|
|
__tablename__ = "study_plan_blocks"
|
|
__table_args__ = (UniqueConstraint("plan_id", "position", name="uq_plan_block"),)
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
plan_id = Column(Integer, ForeignKey("study_plans.id", ondelete="CASCADE"), nullable=False, index=True)
|
|
position = Column(Integer, nullable=False)
|
|
title = Column(String(200), nullable=False)
|
|
question_ids = Column(JSON, nullable=False, default=list)
|
|
|
|
plan = relationship("StudyPlan", back_populates="blocks")
|
|
|
|
|
|
class StudyPlanBlockArticle(Base):
|
|
"""Reading attached to a block, in the order it should be read.
|
|
|
|
A block was questions only, which put the reading that prepares you for them
|
|
somewhere else entirely. This is the "read this, then sit this" pairing.
|
|
"""
|
|
|
|
__tablename__ = "study_plan_block_articles"
|
|
__table_args__ = (UniqueConstraint("block_id", "article_id", name="uq_block_article"),)
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
block_id = Column(Integer, ForeignKey("study_plan_blocks.id", ondelete="CASCADE"), nullable=False, index=True)
|
|
article_id = Column(Integer, ForeignKey("articles.id", ondelete="CASCADE"), nullable=False, index=True)
|
|
position = Column(Integer, default=0)
|
|
|
|
|
|
class StudyPlanArticleRead(Base):
|
|
"""One learner marking one of a block's articles as read.
|
|
|
|
Deliberately separate from `article_views`: opening an article is not the
|
|
same claim as having finished with it, and the plan's progress is the second.
|
|
"""
|
|
|
|
__tablename__ = "study_plan_article_reads"
|
|
__table_args__ = (UniqueConstraint("block_article_id", "user_id", name="uq_block_article_read"),)
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
block_article_id = Column(Integer, ForeignKey("study_plan_block_articles.id", ondelete="CASCADE"), nullable=False, index=True)
|
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
|
read_at = Column(DateTime, default=datetime.utcnow)
|
|
|
|
|
|
class StudyPlanBlockProgress(Base):
|
|
"""Which block a learner has started, and the quiz it produced."""
|
|
|
|
__tablename__ = "study_plan_block_progress"
|
|
__table_args__ = (UniqueConstraint("block_id", "user_id", name="uq_block_progress"),)
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
block_id = Column(Integer, ForeignKey("study_plan_blocks.id", ondelete="CASCADE"), nullable=False, index=True)
|
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
|
quiz_id = Column(Integer, ForeignKey("quizzes.id", ondelete="SET NULL"), nullable=True)
|
|
completed_at = Column(DateTime, nullable=True)
|