feat: add mastery status lookup and the three learner status models - #802
feat: add mastery status lookup and the three learner status models#802jesperhodge wants to merge 5 commits into
Conversation
Implements the authoring and definition half of the CBE data model from ADR-0002: CompetencyCriteriaGroup (internal AND/OR nodes), CompetencyRuleProfile (reusable scoped evaluation defaults) and CompetencyCriterion (leaf nodes). Also adds the taxonomy_overrides_org column that PR openedx#712 left off CompetencyTaxonomy. CompetencyRuleProfile.scope_code is a generated, never-null column with a plain unique constraint. SQL never treats two NULLs as equal, so a unique constraint over the three nullable scope columns would accept two rows with the same scope, and the conditional UniqueConstraint that would normally fix that compiles to a partial index MySQL does not support. Both structural invariants are database check constraints rather than clean() checks, since DRF serializers, QuerySet.update() and bulk_create() never call full_clean(). Payload shape validation stays in clean(), per the issue. Every new foreign key is on_delete=PROTECT with a TODO(openedx#799) comment. That is a fail-closed placeholder, not a per-key decision; openedx#799 sets the real values once openedx#655 lands. openedx_catalog joins .importlinter's root_packages and the src_layering contract, since CompetencyCriteriaGroup.course is the first foreign key from openedx_learning into that app. django-simple-history moves into base.in: it was only ever a transitive dependency of edx-organizations, and setup.py builds install_requires from base.in. Refs openedx#641 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks for the pull request, @jesperhodge! This repository is currently maintained by Once you've gone through the following steps feel free to tag them in a comment and let them know that your changes are ready for engineering review. 🔘 Get product approvalIf you haven't already, check this list to see if your contribution needs to go through the product review process.
🔘 Provide contextTo help your reviewers and other members of the community understand the purpose and larger context of your changes, feel free to add as much of the following information to the PR description as you can:
🔘 Get a green buildIf one or more checks are failing, continue working on your changes until this is no longer the case and your build turns green. 🔘 Update the status of your PRYour PR is currently marked as a draft. After completing the steps above, update its status by clicking "Ready for Review", or removing "WIP" from the title, as appropriate. Where can I find more information?If you'd like to get more details on all aspects of the review process for open source pull requests (OSPRs), check out the following resources: When can I expect my changes to be merged?Our goal is to get community contributions seen and reviewed as efficiently as possible. However, the amount of time that it takes to review and merge a PR can vary significantly based on factors such as:
💡 As a result it may take up to several weeks or months to complete a review and merge your PR. |
openedx#655 closed with an approved design and openedx#799 is now closed as superseded, so both halves of the nine repeated TODO comments were false: openedx#799 does not own the on_delete question, and no follow-up ticket will set "the real" per-foreign-key values. Replaces those nine identical comments with one explanation in the module docstring, which also records the open question openedx#655's design creates for CompetencyCriteriaGroup.tag and CompetencyCriterion.object_tag: that design keeps openedx_tagging ignorant of CBE and promises a plain hard delete for a tag no learner holds mastery against, which PROTECT turns into a ProtectedError whenever an author's criteria tree references the tag and nobody has been graded yet. The PROTECT values themselves are unchanged. They remain the fail-closed default until openedx#655's reviewers settle the question. Refs openedx#641, openedx#655 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
openedx#641 requires at least one test per foreign key asserting that deleting the referenced row matches what the field declares. All nine are PROTECT, so all nine assert ProtectedError, and each inspects ProtectedError.protected_objects rather than only the exception type: a single delete can trip several protected relationships, so a bare pytest.raises would not prove which foreign key did the protecting. Two cases needed isolating to avoid passing for the wrong reason. CatalogCourse.org is itself PROTECT, so the organization test uses an organization with no catalog course attached. Tag.taxonomy is CASCADE, so the competency_taxonomy test omits the tag and group fixtures. A tenth test pins the open openedx#655 question in executable form: deleting a CompetencyTaxonomy whose tag carries a criteria tree raises ProtectedError today, though that design promises the delete succeeds when no learner status exists. It is the test that has to change if the reviewers move CompetencyCriteriaGroup.tag to CASCADE, and says so. Refs openedx#641 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
openedx#655 decided the on_delete question on 2026-09-02, so the four foreign keys between definition tables become CASCADE: CompetencyCriteriaGroup.parent, CompetencyCriteriaGroup.tag, CompetencyCriterion.group and CompetencyCriterion.object_tag. The other five stay PROTECT and are now final. Deleting a Tag nobody holds mastery against has to succeed, and openedx#655's design forbids openedx_tagging from knowing CBE exists, so the tagging-side path cannot clear the criteria tree first. CASCADE lets the delete take the tree with it. parent and group need it too, because Django's collector looks up referencing rows in the database rather than in the set it has already collected, so a parent and child reached in one batch would trip PROTECT and abort the walk partway down. This does not weaken ADR-0002 Decision 7. The four CASCADE links are what carries the collector down to the PROTECT that enforces it, on openedx#642's Student*Status foreign keys one and two levels below the tag, which Django reaches only by walking CASCADE edges. Migration 0002 is edited in place rather than gaining an AlterField, since it is unmerged. The delete tests are reworked accordingly and extended with the transitive cases: a tag delete cascading a whole tree, a group delete at depth taking its descendants and their criteria, and a taxonomy delete reaching through tag to group to criterion. The matching "raises ProtectedError when a learner status exists" halves need openedx#642's tables and belong to that slice, which a comment in the test file records. One cascade test also asserts django-simple-history writes a history_type='-' row per removed row, so the cascade is not silent for audit. Refs openedx#641, openedx#655 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Implements openedx#642's lookup data and learner progress models: the CompetencyMasteryStatuses lookup plus StudentCompetencyCriteriaStatus, StudentCompetencyCriteriaGroupStatus and StudentCompetencyStatus, tracking a learner's mastery at the leaf, group and competency levels. Restacked onto openedx#641. The models live in models/learner_status.py inside the package openedx#641 creates, and the migrations are 0004 and 0005 parented onto openedx#641's 0003_seed_default_rule_profile, so the app keeps a single migration leaf. An earlier revision of this branch added to a top-level models.py and numbered its migrations 0002 and 0003; both would have conflicted with openedx#641 irreconcilably rather than merging. Status rank order lives in pinned primary keys rather than a rank column. The check constraint restricting StudentCompetencyStatus has to compare status_id against a literal, since MySQL allows no subquery in a CHECK, so the ids are load-bearing constants either way and a second ordering column could only drift from them. ADR-0002 Decision 6 also caps the lookup table at two columns. The node foreign keys are PROTECT and that is load-bearing, not defensive: openedx#641's four definition-to-definition foreign keys are CASCADE, so these are the only thing that stops the collector walking from a Tag down through groups to criteria. They are deliberately stricter than the application-layer predicate, which ADR-0002 Decision 7 scopes to the leaf table alone, and fail closed. The user foreign key is CASCADE so this library cannot veto User.delete() platform-wide. Column names follow openedx#641: idiomatic Python attributes with db_column set to the names ADR-0002 Decision 6 uses, so indexes 6, 7 and 8 land on competency_criteria_id, competency_criteria_group_id and oel_tagging_tag_id as specified. Timestamps use manual_date_time_field() rather than auto_now_add/auto_now. auto_now never fires on QuerySet.update(), which is the write path ADR-0004 Decision 4 mandates, so it would leave modified stale on exactly the path that matters. This diverges from the acceptance criteria and is flagged on the pull request. Twenty-four tests, including every ProtectedError case openedx#613 describes. Those moved here from openedx#641 because asserting one needs a Student*Status row and this is the change that creates those tables. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
8fec250 to
9a3ce50
Compare
Implements #642: the mastery status lookup table and the three learner status tables that
track a learner's progress toward demonstrating a competency, at the leaf, group and
competency levels.
Stacked on #641 (PR #800). This branch is based on #800's head, not on
main. #641 turnsapplets/cbe/models.pyinto amodels/package and owns migrations0002and0003, so abranch developed off
mainconflicts with it irreconcilably rather than merging. Because #800'sbranch lives on a fork, GitHub cannot target it as this PR's base, so the diff below includes
#800's commits until #800 merges. Review this PR's own commit (
9a3ce50) in isolation, or waitfor #800.
What lands
Four models in
src/openedx_learning/applets/cbe/models/learner_status.py:CompetencyMasteryStatusStudentCompetencyCriteriaStatusCompetencyCriterionStudentCompetencyCriteriaGroupStatusCompetencyCriteriaGroupStudentCompetencyStatusoel_tagging.TagPlus migrations
0004_learner_status_modelsand0005_seed_competency_mastery_statuses, fourread-only Django admin pages, and 24 tests.
The status ordering lives in pinned primary keys
The data migration seeds
AttemptedNotDemonstratedas id 1,PartiallyAttemptedas 2 andDemonstratedas 3, and aMasteryStatusenum names them. #642 leaves the mechanism openbetween that and a
rankcolumn. Pinned ids, for three reasons:StudentCompetencyStatushas to be a single-rowCHECK, because MySQL allows no subqueryinside one, so it can only compare the row's own
status_idagainst a literal. I verified thecompiled SQL:
Q(status__in=(2, 3))becomesCHECK ("status_id" IN (2, 3)), no join. With theids already load-bearing constants, a
rankcolumn would be a second source of truth for thesame ordering, and two orderings can drift.
idand
status.filter(user=…, criterion=…, status_id__lt=new_id).update(…), which I verified compiles to oneUPDATE … WHERE … <with no subquery. Arankcolumn would need a join.Cost, stated plainly: a fourth status can be added above or below the existing three but not
between them. If that is ever needed, a
rankcolumn can be added then and backfilled fromid.Precedent for pinning a primary key for system-owned lookup data already exists here, in
src/openedx_tagging/migrations/0012_language_taxonomy.py.The enum subclasses
IntegerChoicesrather thanenum.IntEnumfor a verified reason: Django'smigration serializer writes an
IntegerChoicesmember as a bare integer, whereas anIntEnummember serializes as an import of the defining module. That keeps the migrations independent of
later edits to the enum.
Deletion behaviour
Every value here is final. #655 closed with an approved design, and #799 is closed as superseded.
userCASCADEcriterion/group/tag)PROTECTstatusPROTECTThe node
PROTECTvalues are load-bearing, not defensive. #641's fourdefinition-to-definition foreign keys are
CASCADE, so deleting aTagmakes Django's collectorwalk down into its criteria groups and then their criteria. These
PROTECTvalues are the onlything that stops that walk, and they are what turns ADR-0002 Decision 7's guarantee into
behaviour: the delete succeeds when no learner holds status beneath the row, and raises
ProtectedErrorwhen one does.PROTECTis evaluated on every row the collector reaches, notonly the row passed to
delete(), which is why the transitive cases work. #675 re-implements thesame predicate at the API layer for a clean status code; this is the backstop for paths that never
reach it.
They are deliberately stricter than that predicate. ADR-0002 Decision 7, as amended for #655,
names only the leaf table
StudentCompetencyCriteriaStatusas determining whether a record isprotected, treating the two roll-up tables as derived and not independently checked. The database
makes no such distinction, so a roll-up row with no leaf beneath it would also block a delete.
That should not occur, and failing closed is the right default for a backstop.
userisCASCADE, notPROTECT, becausePROTECTwould let this library vetoUser.delete()platform-wide from openedx-platform code that has no reason to know CBE rowsexist.
SET_NULLwas never a candidate: a nulluser_idbreaks the(user_id, node_id)uniqueness the in-place-update design rests on.
CASCADErather thanDO_NOTHING, which anearlier revision of #642 offered, because
DO_NOTHINGdoes not do what its name suggests: Djangoimplements
on_deletein Python, and MySQL builds all of these foreign keys asON DELETE NO ACTIONwhatever is declared, soDO_NOTHINGleaves the constraint in force and raisesIntegrityErrorinstead of orphaning. A real orphan needsdb_constraint=False, which dropsreferential integrity on
user_identirely. Both behaviours were verified against a database.Tests
24 tests. Every
ProtectedErrorcase #613 describes is here rather than in #641, becauseasserting one needs a
Student*Statusrow and this is the change that creates those tables. Eachcase covers both halves, and the blocking status is spread across the three tables so all three
PROTECTvalues are shown firing:oel_tagging_tagCompetencyCriteriaGroupat depthoel_tagging_objecttagoel_tagging_taxonomyPlus: a user delete removing that user's rows across all three models; the single-statement
monotone comparison on the leaf, where ADR-0004 Decision 1 puts automatic writes; the check
constraint on
create(),bulk_create()andQuerySet.update(), none of which callclean();the two roll-up models accepting all three status values, which is the constraint's boundary; one
row per learner and node on each model; and no
historyattribute on any of them.Verification
Green on SQLite and MySQL 8: 74 tests (24 here, 50 from #800),
pylint,pycodestyle,pydocstyle,isort,mypy,lint-imports, andmakemigrations openedx_learning --check.showmigrationsconfirms a single leaf, and the chain applies from an empty database with thethree lookup rows seeded.
All ten ADR-0002 Decision 5 indexes verified present, by introspecting a migrated MySQL
database rather than by reading the models. The five that must be unique are unique: 6, 7, 8, 9
and 10. Django's SQLite introspection cannot do this check, as it fails on
CompetencyRuleProfile'sGeneratedField.Two deliberate divergences from the acceptance criteria
Timestamps use
manual_date_time_field(), notauto_now_add/auto_now.auto_nowisapplied by
DateTimeField.pre_save, which only runs onModel.save(); I verified thatQuerySet.update()carries only the values passed to it. So underauto_now,modifiedwould gostale on exactly the conditional-
UPDATEpath ADR-0004 Decision 4 mandates. This also matches therepo's own rule:
manual_date_time_field()is used in the publishing and versioning core, whereone logical operation writes many rows that should share a timestamp, and a rollup writing a leaf
and its ancestors across several commits is squarely that case. The field names,
createdandmodified, are unchanged and match OEP-38.The model class is
CompetencyMasteryStatus, singular, where #642 and ADR-0002 writeCompetencyMasteryStatuses. Django models are singular by convention, and #641 applies the sameresolution in naming its class
CompetencyCriterion.A third divergence from earlier revisions of this PR is now resolved: the foreign key columns
carry explicit
db_columnvalues matching ADR-0002 Decision 6 exactly, following #641's approachof pairing an idiomatic Python attribute with the ADR's column name.
Known gaps
pii_checkdoes not pass, for reasons that predate this feature. Every model #613 adds isannotated, including #641's three
Historical*models: none of the 20 models the coverage reportlists as uncovered belongs to
openedx_learning. But the repo sits at 71.8% coverage with twolint errors (
openedx_content.Draftandopenedx_content.PublishableEntityVersionare bothannotated and safelisted), and I confirmed both are identical on #800's head with none of this
work applied. #642's whole-feature gate asks for
make pii_checkat 100%, which cannot be reachedwithout annotating 20 models across
oel_*,openedx_catalog,openedx_content,organizationsand
test_django_app. That is out of scope here and needs its own issue.openedx-platform companion work is unfiled. That repo lists every openedx-core model
individually in its own safe list, so the first pin bump including #641 and this change drops its
pii_checkbelow target until ten entries are added. #642's Out of scope section records this;filing it is not this PR's work.
🤖 Generated with Claude Code