feat: add mastery status lookup and learner competency status models - #802
feat: add mastery status lookup and learner competency status models#802jesperhodge wants to merge 3 commits into
Conversation
Implements the part of openedx#642 that does not depend on openedx#641: the shared CompetencyMasteryStatus lookup table and StudentCompetencyStatus, which holds one row per learner per competency. The leaf-level and group-level status models follow once openedx#641 lands, since their foreign keys point at tables openedx#641 creates. The three status values are seeded with pinned primary keys in rank order, lowest to highest. That is what makes the ordering visible to the database, so raising a learner's status can be written as one conditional UPDATE guarded by status_id < new_status_id, as ADR-0004 Decision 4 requires. It is also forced by the check constraint: MySQL does not allow a subquery in a CHECK, so the allow list has to compare status_id against literals. Two deliberate divergences from the acceptance criteria, both explained in the pull request: - created and modified use manual_date_time_field() rather than auto_now_add/auto_now. auto_now never fires on QuerySet.update(), which is the write path this ticket exists to enable, so it would leave the column stale exactly where it matters. - The model class is CompetencyMasteryStatus, singular, following Django convention and the same resolution openedx#641 applies to CompetencyCriterion. Every new foreign key is on_delete=PROTECT with a TODO(openedx#799) comment. That is a fail-closed placeholder, not a per-foreign-key decision; openedx#799 sets the real values. 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 that enforces archive-versus-delete at the application layer, driven by a lock flag on ObjectTag, so openedx#799 is closed as superseded and no ticket will revisit these on_delete values. The three TODO(openedx#799) comments were false on both halves and are gone. The user foreign key stops being PROTECT. PROTECT there let this library veto User.delete() platform-wide, from openedx-platform code that has no reason to know CBE rows exist. It is now CASCADE: a learner's competency status is a derived fact about that learner, so it goes when they do. CASCADE rather than DO_NOTHING, though openedx#642 offers both. DO_NOTHING does not orphan the row: Django emits no SQL for the parent delete but the database foreign key stays, so deleting a user raises IntegrityError instead. A real orphan needs db_constraint=False as well, which drops referential integrity on user_id entirely. Both behaviours were verified against SQLite before choosing. tag and status keep PROTECT, and the reasons are now in the code rather than implied. The lookup table is system-owned immutable data that PROTECT keeps an admin or a later migration from pulling out from under live rows. On tag, PROTECT is a backstop rather than the intended path, since the approved design archives a tag in use and archiving never reaches on_delete; its value is covering the rows where the two tests disagree. Four delete tests, one per foreign key plus the same-batch case, since Django's PROTECT fires even when the referencing row is inside the batch being deleted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The comment claimed the approved delete design archives a Tag instead of deleting it, so PROTECT was only a backstop against a different check. Both halves were wrong. openedx#642, rewritten today, is explicit: openedx#641's four definition-to-definition foreign keys are CASCADE, so deleting a Tag walks down into its criteria groups and their criteria, and the PROTECT values on the status tables are the only thing that stops that walk. They are what turns ADR-0002 Decision 7 into behavior, a delete that succeeds when no learner holds status beneath the row and raises ProtectedError when one does. Archiving belongs to openedx#716 and a not-yet-filed openedx_tagging ticket, and applies to the criteria and tagging rows rather than replacing a Tag delete. openedx#675 re-implements the same predicate at the API layer for a clean status code, and PROTECT is the backstop for paths that never reach it. No behavior changes. The on_delete values already matched the revised criteria: user CASCADE, tag and status PROTECT. Also reframes the same-batch delete test, which is really a transitive case: Taxonomy to Tag is CASCADE, so it already demonstrates the mechanism that will guard openedx#641's chain, without waiting for openedx#641. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
What this is
Part of #642. It adds the shared mastery status lookup table and the competency-level
learner status table, plus the two migrations that create and seed them.
This does not close #642. Two of that issue's three learner-status models have foreign
keys into tables #641 creates (
CompetencyCriteriaandCompetencyCriteriaGroup), and#641 has not merged, so those two models have nothing to point at. They follow in a second
pull request. The scope split is recorded as a comment on #642.
CompetencyMasteryStatuslookup modelStudentCompetencyCriteriaStatusStudentCompetencyStatusStudentCompetencyCriteriaGroupStatusThe whole-feature gates #642 carries (all ten indexes present,
make pii_checkat 100%across every model #613 adds, and migrations applying from scratch across all three merged
tickets) all need #641's tables to exist before they mean anything, so they are verified in
slice 2.
How the status ordering works
The three status values are seeded with pinned primary keys in rank order, lowest to
highest:
AttemptedNotDemonstratedis 1,PartiallyAttemptedis 2,Demonstratedis 3.A
MasteryStatusenum names them so no integer literal appears in model code.Pinning the order into the primary key is what makes it visible to the database. Raising a
learner's status is then a single statement,
UPDATE ... SET status_id = %s WHERE ... AND status_id < %s, with no join and no subquery. That is what ADR-0004 Decision 4 needs: aread, a comparison in Python, and a write would let two concurrent writers each read the
same old value, and the later write would then lower what the earlier one had raised.
Pinned ids are not just convenient here, they are forced. The constraint that keeps
AttemptedNotDemonstratedout ofStudentCompetencyStatushas to be a single-rowCHECK,because MySQL does not allow a subquery inside one. A single-row check can only compare the
row's own
status_idagainst literals, so stable ids are required either way, and aseparate
rankcolumn would be a second source of truth for the same ordering. It wouldalso be an extra column, which the acceptance criteria cap at ADR-0002 Decision 6's two.
The 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 andbackfilled from
id.Seeding a system-owned lookup row at a pinned id follows existing precedent in this repo,
src/openedx_tagging/migrations/0012_language_taxonomy.py.Deliberate divergences from the acceptance criteria
1.
createdandmodifiedusemanual_date_time_field(), notauto_now_add/auto_now.The criteria name the auto flags. They cannot work on this model.
auto_nowis applied byDateTimeField.pre_save, which only runs onModel.save();QuerySet.update()carriesonly the values passed to it. So
auto_now=Truewould leavemodifiedstale on exactlythe conditional-
UPDATEpath that ADR-0004 Decision 4 mandates and that this PR exists toenable. A field that looks automatic but silently is not is worse than one that makes the
caller pass a value.
manual_date_time_field()is also this repo's own convention for this shape of model. Itis used throughout the publishing and versioning core (
LearningPackage,PublishableEntity,PublishLog,DraftChangeLog,Content), where one logicaloperation writes many rows that should share one timestamp, and
auto_now_addis used inperipheral single-row models. Learner status writes are the former: ADR-0004 opens by
noting that one grade change updates the leaf and every row above it, for many learners at
once. Because Decision 3 commits each level separately and celery may retry, those rows are
written at several wall-clock times, so a caller-supplied timestamp is the only way one
cascade's rows carry one "as of" value.
The field names are unchanged.
createdandmodifiedare what OEP-38 mandates and whatboth this repo and openedx-platform use.
2. The model class is
CompetencyMasteryStatus, singular.#642, #613 and ADR-0002 all write
CompetencyMasteryStatuses. Django models are singular byconvention, and #641 already applies this same resolution within this ticket family, naming
its class
CompetencyCriterionfor theCompetencyCriteriatable. Table naming follows#640's precedent, letting Django derive it.
3. Field names are idiomatic Django, so index 8's columns differ from the ADR's spelling.
ADR-0002 writes the foreign key columns as
oel_tagging_tag_idandstatus_id. The fieldshere are
tagandstatus, matchingObjectTag.tagin this repo, so index 8 lands on(user_id, tag_id)rather than(user_id, oel_tagging_tag_id). Reviewers checking thatcriterion literally should not read this as a miss.
Deletion behaviour
Each
on_deletevalue here is final, and #642 as rewritten on 2026-09-02 records why. #655closed with an approved design, #799 is closed as superseded, and no follow-up ticket will
revisit these values. The earlier
TODO(#799)comments are gone.userCASCADEtagPROTECTCASCADEchain, which is what makes ADR-0002 Decision 7's guarantee real.statusPROTECTPROTECTon the status foreign keys is load-bearing, not defensive. #641's fourdefinition-to-definition foreign keys are
CASCADE, decided on #655. So deleting aTagmakes Django's collector walk down into its criteria groups and then into their criteria. The
PROTECTvalues on the status tables are the only thing that stops that walk, and they arewhat turns ADR-0002 Decision 7 into behavior rather than intent: the delete succeeds when no
learner holds a status beneath the row, and raises
ProtectedErrorwhen one does. Djangoevaluates
PROTECTon every row the collector reaches, not only the row passed todelete(),which is what makes the transitive cases work. #675 re-implements the same predicate at the
API layer so the caller gets a clean status code instead of a 500; the database-level
PROTECTis the backstop for the paths that never reach #675.An earlier revision of this description said the approved design archives a
Taginstead ofdeleting it, making
PROTECTa backstop against a different check. That was wrong on bothcounts and has been corrected here and in the code comment. Archiving belongs to #716 and to a
not-yet-filed
openedx_taggingticket, and it applies to the criteria and tagging rows ratherthan replacing a
Tagdelete.userisCASCADE, notPROTECT.PROTECTthere let this library vetoUser.delete()platform-wide, from
openedx-platformcode that has no reason to know CBE rows exist.SET_NULLwas never a candidate: a nulluser_idwould break the(user_id, tag_id)uniqueness the in-place-update design rests on.
CASCADErather thanDO_NOTHING, which #642 offered as the alternative, becauseDO_NOTHINGdoes not do what its name suggests. Django implementson_deletein Python, notin the database: MySQL builds all three of these foreign keys as
ON DELETE NO ACTIONwhatever value is declared. So under
DO_NOTHING, Django emits no SQL for the parent deletewhile the database constraint stays in force, and deleting a user raises
IntegrityError: FOREIGN KEY constraint failedrather than leaving an orphan. A genuine orphan additionallyneeds
db_constraint=False, which removes referential integrity onuser_idaltogether.Both behaviours were verified against a real database before choosing.
statusisPROTECTbecause the mastery status table is seeded by migration and neverdeleted, so
PROTECTstops a later migration or an admin from removing a status value thatlive rows still reference.
Tests
Four delete tests, one per foreign key plus a transitive case:
Tagor a referenced status row raisesProtectedError.Taxonomyabove a referencedTagraisesProtectedError.Tag.taxonomyisCASCADE, so this is a genuine transitive case, and it already exercises the mechanism thatwill guard Competency criteria models (authoring/definition layer) #641's chain from
Tagdown through groups to criteria, without waiting forCompetency criteria models (authoring/definition layer) #641. It also covers
PROTECTfiring when the referencing row is inside the batch beingdeleted, since only
RESTRICTexempts that case.#642's remaining transitive criteria, deleting a
Tagor a nestedCompetencyCriteriaGroupwith a leaf status row beneath it, need #641's tables and belong to the slice that follows it.
That criterion says so itself. The same applies to the criteria requiring
CASCADEon theuserforeign key andPROTECTon the definition-table foreign keys of all three statusmodels: this pull request ships one of the three, and satisfies them for it.
Nothing here implements deletion behavior in code: no
delete()override, noarchive-versus-delete branch, no deletion-lock field.
Verification
Run from this branch against the repo's own tooling. Everything below passed:
pytest tests/openedx_learning(SQLite)pytest tests/openedx_learning(MySQL 8.4)makemigrations openedx_learning --check --dry-runpylint,pycodestyle,pydocstyle,isort,mypylint-importsNothing was suppressed to get there: no
# noqa,# pylint: disable, or# type: ignorewas added.
The MySQL run matters here, because the check constraint is the centre of this change and a
green SQLite suite is not evidence for it. Inspecting the schema MySQL actually built:
Two pre-existing failures on
mainare unrelated to this branch and are not addressed here:code_annotations --lintreportsopenedx_content.Draftandopenedx_content.PublishableEntityVersionas both annotated and safelisted, andpydocstylereports a missing package docstring on
tests/openedx_learning/__init__.py. Both reproduceon an untouched checkout. The two models added here carry inline
.. no_pii:annotations andneither appears in the uncovered list.
Tests
Eleven behaviours in
tests/openedx_learning/applets/cbe/test_mastery.py: the seed'scontents and rank order; uniqueness of
status; the conditional raise being a no-op againsta higher stored value and effective against a lower one, asserted on the row count returned
by
update(); rejection ofAttemptedNotDemonstratedoncreate(),bulk_create()andQuerySet.update(), since none of the last two callclean(); acceptance of the twopermitted values; the one-row-per-learner-and-competency constraint;
createdandmodifiedbeing required and UTC-validated; a conditional raise carrying
modifiedwithout touchingcreated; and the absence of any history package.Admin
Both models get a bare-bones page subclassing
ReadOnlyModelAdminfromopenedx_django_lib.admin_utils, whose docstring is the standing instruction to do sorather than subclass
ModelAdmindirectly. Read-only is also right on the merits: thelookup table is immutable configuration per ADR-0002 Decision 6.1, and an editable
StudentCompetencyStatuspage would be the staff-correction path, which ADR-0004 Decision 6requires to take a row lock and recompute every ancestor. None of that exists yet.
Merge order
#642 merges after #641, which is draft PR #800. Both branches add migrations to this same app:
#800 has
0002_competency_criteriaand0003_seed_default_rule_profile, this one has0002_competency_mastery_status_modelsand0003_seed_competency_mastery_statuses. So thisbranch needs a rebase and a renumber to
0004/0005once #800 lands. Docstrings here referto the seed migration by name rather than number, so renumbering leaves no stale references.
#800 also converts
applets/cbe/models.pyinto amodels/package, withcompetency_taxonomy.pyandcriteria.pyin it. The slice that follows moves these two modelsinto that package as
models/mastery.py. That needs no migration, since a model's table namecomes from its app label and class name rather than its module path, but the package
__init__.pyhas to re-export the names so the wildcard import insrc/openedx_learning/models.pykeeps working, andadmin.py's import has to be repointed.🤖 Generated with Claude Code