Part of the shared-curriculum sub-modules umbrella (#396), phase A of 4. Full design: _docs/planning/shared-curriculum-submodules-design.md (sections "Refined design" and "Constraints and what the renderers must know").
Goal
Additive-only database schema for shared-curriculum sub-modules and per-cohort homework anchoring. No behavior change for any existing content — every module currently has zero sub-modules and must keep rendering exactly as today.
Scope
- New model
courses.models.shared_curriculum.SharedSubModule: required FK to SharedModule (related_name="sub_modules"), position (int), slug, title, summary (optional), source provenance fields mirroring SharedModule's own (source_content_id etc.). No overview_markdown, no independent published/retired_at — a sub-module is a grouping, not an independently-publishable unit. Unique constraints: (module, position), (module, slug), (module, source_content_id).
SharedLesson gains a nullable sub_module FK. clean() validates sub_module.module_id == module_id when set.
CohortSharedModule gains a nullable sub_module FK (the homework anchor selector) and:
UniqueConstraint(fields=("cohort", "shared_module"), condition=Q(sub_module__isnull=True))
UniqueConstraint(fields=("cohort", "sub_module"), condition=Q(sub_module__isnull=False))
UniqueConstraint(fields=("terminal_homework",), condition=Q(terminal_homework__isnull=False))
clean(): sub_module.module_id == shared_module_id when sub_module is set.
- Update the model's docstring: a placement is one cohort's homework anchor on one node (module or one of its sub-modules) of a shared module — not merely "optional."
- Migrations: purely additive (new table, new nullable columns, new constraints on existing tables — verify the three new
CohortSharedModule constraints don't reject any existing row; they shouldn't, since every existing row has sub_module IS NULL).
- Django admin: register
SharedSubModule with reasonable list/search config, consistent with how SharedModule is already registered.
Non-goals
- No importer/parser changes (phase B).
- No rendering changes (phase C).
- No
zoomcamp-ops/module.yaml/cohort.yaml schema changes (phase D).
- No real sub-module data — this phase ships with zero
SharedSubModule rows in every environment.
Dependencies
Acceptance criteria
Part of the shared-curriculum sub-modules umbrella (#396), phase A of 4. Full design:
_docs/planning/shared-curriculum-submodules-design.md(sections "Refined design" and "Constraints and what the renderers must know").Goal
Additive-only database schema for shared-curriculum sub-modules and per-cohort homework anchoring. No behavior change for any existing content — every module currently has zero sub-modules and must keep rendering exactly as today.
Scope
courses.models.shared_curriculum.SharedSubModule: required FK toSharedModule(related_name="sub_modules"),position(int),slug,title,summary(optional), source provenance fields mirroringSharedModule's own (source_content_idetc.). Nooverview_markdown, no independentpublished/retired_at— a sub-module is a grouping, not an independently-publishable unit. Unique constraints:(module, position),(module, slug),(module, source_content_id).SharedLessongains a nullablesub_moduleFK.clean()validatessub_module.module_id == module_idwhen set.CohortSharedModulegains a nullablesub_moduleFK (the homework anchor selector) and:UniqueConstraint(fields=("cohort", "shared_module"), condition=Q(sub_module__isnull=True))UniqueConstraint(fields=("cohort", "sub_module"), condition=Q(sub_module__isnull=False))UniqueConstraint(fields=("terminal_homework",), condition=Q(terminal_homework__isnull=False))clean():sub_module.module_id == shared_module_idwhensub_moduleis set.CohortSharedModuleconstraints don't reject any existing row; they shouldn't, since every existing row hassub_module IS NULL).SharedSubModulewith reasonable list/search config, consistent with howSharedModuleis already registered.Non-goals
zoomcamp-ops/module.yaml/cohort.yamlschema changes (phase D).SharedSubModulerows in every environment.Dependencies
Acceptance criteria
make django-check/make migrations-checkpass.make test(full suite) passes with zero behavior change to any existing rendered page (verify by diffing rendered HTML for a couple of real shared-curriculum pages before/after, e.g. an llm-zoomcamp module page).SharedSubModuleuniqueness constraints,SharedLesson.clean()cross-module rejection,CohortSharedModule.clean()cross-module rejection, all three newCohortSharedModuleconstraints (module-level dup rejected, sub-module-level dup rejected, a homework anchored twice rejected).manage.py migrateapplies cleanly end to end on a fresh database and on the existing local dev database; migration reverses cleanly.SharedSubModulewithout error.