There will be no courses. What was there: one draft called "jk" with two empty lessons, and 4,000 lines of code around it — courses, modules, lessons, enrolments, per-lesson progress, SCORM, BigBlueButton, completion certificates, three React pages, a router, two models. Its real cost was everywhere else. Every query that measured practice had to remember `Quiz.course_id.is_(None)`, and forgetting it in one place would have silently mixed course attempts into a learner's analytics; the bank predicate carried a subquery to exclude a course's own questions from every search, recommendation and share; quiz access had a second, parallel rule about enrolment. All of that is gone, so the remaining rules say what they mean. `quizzes.allow_review` goes with it. It was only ever enforced for a course quiz, so it had become a promise nothing keeps — the public session page was still offering "no answer review" about sessions that review fine. The fixtures' question 5 lived in a course quiz and stood for "a question that exists but is not in your bank". There is no such thing now — a question is in the bank unless it is deleted — so the counts it kept out of the numbers are back in, and the tests that turned on it now turn on deletion or on the attempt that actually holds a question. Files the LMS uploaded stay on disk and stay protected: LEGACY_LMS_PREFIXES in app/utils/upload_access.py is what keeps them unreachable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
116 lines
5.6 KiB
Python
116 lines
5.6 KiB
Python
"""Remove the LMS
|
|
|
|
Courses, modules, lessons, enrolments, per-lesson progress, SCORM packages,
|
|
BigBlueButton sessions and completion certificates. None of it was used — one
|
|
draft course called "jk" with two empty lessons — and all of it was in the way:
|
|
every query that counted practice had to remember to exclude course attempts,
|
|
and every question predicate had to exclude a course's own questions.
|
|
|
|
`quizzes.allow_review` goes with them. It was only ever enforced for a course
|
|
quiz, so once courses are gone it is a promise nothing keeps — the public quiz
|
|
page was still saying "no answer review" about sessions that reviewed fine.
|
|
|
|
The tables are dropped rather than left orphaned: an unused table with foreign
|
|
keys into live ones is a trap for the next person reading the schema. Files the
|
|
LMS uploaded stay on disk and stay protected — see LEGACY_LMS_PREFIXES in
|
|
app/utils/upload_access.py.
|
|
|
|
Revision ID: n4e5f6a7b8c9
|
|
Revises: m3d4e5f6a7b8
|
|
"""
|
|
import sqlalchemy as sa
|
|
from alembic import op
|
|
|
|
revision = "n4e5f6a7b8c9"
|
|
down_revision = "m3d4e5f6a7b8"
|
|
branch_labels = None
|
|
depends_on = None
|
|
|
|
# Children first: each drop removes the foreign keys pointing at the next.
|
|
TABLES = ("course_lesson_progress", "course_lessons", "course_enrollments",
|
|
"course_modules", "courses")
|
|
|
|
|
|
def upgrade() -> None:
|
|
inspector = sa.inspect(op.get_bind())
|
|
columns = {c["name"] for c in inspector.get_columns("quizzes")}
|
|
# Dropped before the tables, because it is the foreign key into them.
|
|
if "course_id" in columns:
|
|
op.drop_column("quizzes", "course_id")
|
|
if "allow_review" in columns:
|
|
op.drop_column("quizzes", "allow_review")
|
|
existing = set(inspector.get_table_names())
|
|
for table in TABLES:
|
|
if table in existing:
|
|
op.drop_table(table)
|
|
|
|
|
|
def downgrade() -> None:
|
|
# The schema can come back; the LMS cannot, and neither can the rows.
|
|
op.create_table(
|
|
"courses",
|
|
sa.Column("id", sa.Integer(), primary_key=True),
|
|
sa.Column("title", sa.String(), nullable=False),
|
|
sa.Column("description", sa.Text()),
|
|
sa.Column("thumbnail_path", sa.String()),
|
|
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
|
sa.Column("status", sa.String(), server_default="draft"),
|
|
sa.Column("is_featured", sa.Integer(), server_default="0"),
|
|
sa.Column("requires_subscription", sa.Integer(), server_default="0"),
|
|
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now()),
|
|
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now()),
|
|
)
|
|
op.create_table(
|
|
"course_modules",
|
|
sa.Column("id", sa.Integer(), primary_key=True),
|
|
sa.Column("course_id", sa.Integer(), sa.ForeignKey("courses.id", ondelete="CASCADE"), nullable=False),
|
|
sa.Column("title", sa.String(), nullable=False),
|
|
sa.Column("description", sa.Text()),
|
|
sa.Column("position", sa.Integer(), server_default="0"),
|
|
)
|
|
op.create_table(
|
|
"course_lessons",
|
|
sa.Column("id", sa.Integer(), primary_key=True),
|
|
sa.Column("module_id", sa.Integer(), sa.ForeignKey("course_modules.id", ondelete="CASCADE"), nullable=False),
|
|
sa.Column("title", sa.String(), nullable=False),
|
|
sa.Column("description", sa.Text()),
|
|
sa.Column("position", sa.Integer(), server_default="0"),
|
|
sa.Column("lesson_type", sa.String(), nullable=False),
|
|
sa.Column("content_text", sa.Text()),
|
|
sa.Column("video_url", sa.String()),
|
|
sa.Column("video_provider", sa.String()),
|
|
sa.Column("local_file_path", sa.String()),
|
|
sa.Column("quiz_id", sa.Integer(), sa.ForeignKey("quizzes.id", ondelete="SET NULL")),
|
|
sa.Column("live_session_url", sa.String()),
|
|
sa.Column("live_session_start", sa.DateTime()),
|
|
sa.Column("live_session_end", sa.DateTime()),
|
|
sa.Column("bbb_meeting_id", sa.String()),
|
|
sa.Column("duration_minutes", sa.Integer()),
|
|
sa.Column("is_required", sa.Integer(), server_default="1"),
|
|
)
|
|
op.create_table(
|
|
"course_enrollments",
|
|
sa.Column("id", sa.Integer(), primary_key=True),
|
|
sa.Column("course_id", sa.Integer(), sa.ForeignKey("courses.id", ondelete="CASCADE"), nullable=False),
|
|
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
|
sa.Column("enrolled_at", sa.DateTime(), server_default=sa.func.now()),
|
|
sa.Column("completed_at", sa.DateTime()),
|
|
sa.Column("progress_pct", sa.Float(), server_default="0"),
|
|
sa.Column("status", sa.String(), server_default="enrolled"),
|
|
sa.UniqueConstraint("course_id", "user_id"),
|
|
)
|
|
op.create_table(
|
|
"course_lesson_progress",
|
|
sa.Column("id", sa.Integer(), primary_key=True),
|
|
sa.Column("enrollment_id", sa.Integer(), sa.ForeignKey("course_enrollments.id", ondelete="CASCADE"), nullable=False),
|
|
sa.Column("lesson_id", sa.Integer(), sa.ForeignKey("course_lessons.id", ondelete="CASCADE"), nullable=False),
|
|
sa.Column("status", sa.String(), server_default="not_started"),
|
|
sa.Column("started_at", sa.DateTime()),
|
|
sa.Column("completed_at", sa.DateTime()),
|
|
sa.Column("score", sa.Float()),
|
|
sa.Column("time_spent_seconds", sa.Integer(), server_default="0"),
|
|
sa.UniqueConstraint("enrollment_id", "lesson_id"),
|
|
)
|
|
op.add_column("quizzes", sa.Column("course_id", sa.Integer(),
|
|
sa.ForeignKey("courses.id", ondelete="CASCADE"), nullable=True))
|
|
op.add_column("quizzes", sa.Column("allow_review", sa.Integer(), server_default="1"))
|