Skip to content

feat: add mastery status lookup and learner competency status models - #802

Draft
jesperhodge wants to merge 3 commits into
openedx:mainfrom
jesperhodge:jesperhodge/feat--642-mastery-status-models
Draft

feat: add mastery status lookup and learner competency status models#802
jesperhodge wants to merge 3 commits into
openedx:mainfrom
jesperhodge:jesperhodge/feat--642-mastery-status-models

Conversation

@jesperhodge

@jesperhodge jesperhodge commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

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 (CompetencyCriteria and CompetencyCriteriaGroup), 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.

In this PR Deferred to slice 2
CompetencyMasteryStatus lookup model StudentCompetencyCriteriaStatus
StudentCompetencyStatus StudentCompetencyCriteriaGroupStatus
ADR-0002 Decision 5 indexes 8 and 10 Indexes 6 and 7
Schema migration and seed data migration The three whole-feature gates listed below

The whole-feature gates #642 carries (all ten indexes present, make pii_check at 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: AttemptedNotDemonstrated is 1, PartiallyAttempted is 2, Demonstrated is 3.
A MasteryStatus enum 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: a
read, 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
AttemptedNotDemonstrated out of StudentCompetencyStatus has to be a single-row CHECK,
because MySQL does not allow a subquery inside one. A single-row check can only compare the
row's own status_id against literals, so stable ids are required either way, and a
separate rank column would be a second source of truth for the same ordering. It would
also 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 rank column can be added then and
backfilled 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. created and modified use manual_date_time_field(), not auto_now_add/auto_now.

The criteria name the auto flags. They cannot work on this model. auto_now is applied by
DateTimeField.pre_save, which only runs on Model.save(); QuerySet.update() carries
only the values passed to it. So auto_now=True would leave modified stale on exactly
the conditional-UPDATE path that ADR-0004 Decision 4 mandates and that this PR exists to
enable. 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. It
is used throughout the publishing and versioning core (LearningPackage,
PublishableEntity, PublishLog, DraftChangeLog, Content), where one logical
operation writes many rows that should share one timestamp, and auto_now_add is used in
peripheral 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. created and modified are what OEP-38 mandates and what
both 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 by
convention, and #641 already applies this same resolution within this ticket family, naming
its class CompetencyCriterion for the CompetencyCriteria table. 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_id and status_id. The fields
here are tag and status, matching ObjectTag.tag in this repo, so index 8 lands on
(user_id, tag_id) rather than (user_id, oel_tagging_tag_id). Reviewers checking that
criterion literally should not read this as a miss.

Deletion behaviour

Each on_delete value here is final, and #642 as rewritten on 2026-09-02 records why. #655
closed 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.

Foreign key Value Why
user CASCADE A learner's competency status is a derived fact about that learner, so it goes when they do.
tag PROTECT Stops #641's CASCADE chain, which is what makes ADR-0002 Decision 7's guarantee real.
status PROTECT The lookup table is system-owned immutable data that live rows reference.

PROTECT on the status foreign keys is load-bearing, not defensive. #641's four
definition-to-definition foreign keys are CASCADE, decided on #655. So deleting a Tag
makes Django's collector walk down into its criteria groups and then into their criteria. The
PROTECT values on the status tables are the only thing that stops that walk, and they are
what turns ADR-0002 Decision 7 into behavior rather than intent: the delete succeeds when no
learner holds a status beneath the row, and raises ProtectedError when one does. Django
evaluates PROTECT on every row the collector reaches, not only the row passed to delete(),
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
PROTECT is the backstop for the paths that never reach #675.

An earlier revision of this description said the approved design archives a Tag instead of
deleting it, making PROTECT a backstop against a different check. That was wrong on both
counts and has been corrected here and in the code comment. Archiving belongs to #716 and to a
not-yet-filed openedx_tagging ticket, and it applies to the criteria and tagging rows rather
than replacing a Tag delete.

user is CASCADE, not PROTECT. PROTECT there let this library veto User.delete()
platform-wide, from openedx-platform code that has no reason to know CBE rows exist.
SET_NULL was never a candidate: a null user_id would break the (user_id, tag_id)
uniqueness the in-place-update design rests on.

CASCADE rather than DO_NOTHING, which #642 offered as the alternative, because
DO_NOTHING does not do what its name suggests. Django implements on_delete in Python, not
in the database: MySQL builds all three of these foreign keys as ON DELETE NO ACTION
whatever value is declared. So under DO_NOTHING, Django emits no SQL for the parent delete
while the database constraint stays in force, and deleting a user raises IntegrityError: FOREIGN KEY constraint failed rather than leaving an orphan. A genuine orphan additionally
needs db_constraint=False, which removes referential integrity on user_id altogether.
Both behaviours were verified against a real database before choosing.

status is PROTECT because the mastery status table is seeded by migration and never
deleted, so PROTECT stops a later migration or an admin from removing a status value that
live rows still reference.

Tests

Four delete tests, one per foreign key plus a transitive case:

  • Deleting a referenced Tag or a referenced status row raises ProtectedError.
  • Deleting a learner removes their status row and leaves the competency untouched.
  • Deleting the Taxonomy above a referenced Tag raises ProtectedError. Tag.taxonomy is
    CASCADE, so this is a genuine transitive case, and it already exercises the mechanism that
    will guard Competency criteria models (authoring/definition layer) #641's chain from Tag down through groups to criteria, without waiting for
    Competency criteria models (authoring/definition layer) #641. It also covers PROTECT firing when the referencing row is inside the batch being
    deleted, since only RESTRICT exempts that case.

#642's remaining transitive criteria, deleting a Tag or a nested CompetencyCriteriaGroup
with 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 CASCADE on the
user foreign key and PROTECT on the definition-table foreign keys of all three status
models: this pull request ships one of the three, and satisfies them for it.

Nothing here implements deletion behavior in code: no delete() override, no
archive-versus-delete branch, no deletion-lock field.

Verification

Run from this branch against the repo's own tooling. Everything below passed:

Check Result
pytest tests/openedx_learning (SQLite) 19 passed
pytest tests/openedx_learning (MySQL 8.4) 19 passed
makemigrations openedx_learning --check --dry-run No changes detected
pylint, pycodestyle, pydocstyle, isort, mypy clean
lint-imports 2 contracts kept, 0 broken

Nothing was suppressed to get there: no # noqa, # pylint: disable, or # type: ignore
was 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:

CHECK constraint: oex_learning_studentcompetencystatus_status_allowed -> (`status_id` in (2,3))
unique index:     status                                              -> ['status']            (index 10)
unique index:     oex_learning_studentcompetencystatus_user_tag_uniq  -> ['user_id', 'tag_id'] (index 8)
seeded rows:      (1, 'AttemptedNotDemonstrated') (2, 'PartiallyAttempted') (3, 'Demonstrated')

Two pre-existing failures on main are unrelated to this branch and are not addressed here:
code_annotations --lint reports openedx_content.Draft and
openedx_content.PublishableEntityVersion as both annotated and safelisted, and pydocstyle
reports a missing package docstring on tests/openedx_learning/__init__.py. Both reproduce
on an untouched checkout. The two models added here carry inline .. no_pii: annotations and
neither appears in the uncovered list.

Tests

Eleven behaviours in tests/openedx_learning/applets/cbe/test_mastery.py: the seed's
contents and rank order; uniqueness of status; the conditional raise being a no-op against
a higher stored value and effective against a lower one, asserted on the row count returned
by update(); rejection of AttemptedNotDemonstrated on create(), bulk_create() and
QuerySet.update(), since none of the last two call clean(); acceptance of the two
permitted values; the one-row-per-learner-and-competency constraint; created and modified
being required and UTC-validated; a conditional raise carrying modified without touching
created; and the absence of any history package.

Admin

Both models get a bare-bones page subclassing ReadOnlyModelAdmin from
openedx_django_lib.admin_utils, whose docstring is the standing instruction to do so
rather than subclass ModelAdmin directly. Read-only is also right on the merits: the
lookup table is immutable configuration per ADR-0002 Decision 6.1, and an editable
StudentCompetencyStatus page would be the staff-correction path, which ADR-0004 Decision 6
requires 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_criteria and 0003_seed_default_rule_profile, this one has
0002_competency_mastery_status_models and 0003_seed_competency_mastery_statuses. So this
branch needs a rebase and a renumber to 0004/0005 once #800 lands. Docstrings here refer
to the seed migration by name rather than number, so renumbering leaves no stale references.

#800 also converts applets/cbe/models.py into a models/ package, with
competency_taxonomy.py and criteria.py in it. The slice that follows moves these two models
into that package as models/mastery.py. That needs no migration, since a model's table name
comes from its app label and class name rather than its module path, but the package
__init__.py has to re-export the names so the wildcard import in
src/openedx_learning/models.py keeps working, and admin.py's import has to be repointed.

🤖 Generated with Claude Code

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>
@openedx-webhooks openedx-webhooks added the open-source-contribution PR author is not from Axim or 2U label Sep 2, 2026
@openedx-webhooks

Copy link
Copy Markdown

Thanks for the pull request, @jesperhodge!

This repository is currently maintained by @axim-engineering.

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 approval

If you haven't already, check this list to see if your contribution needs to go through the product review process.

  • If it does, you'll need to submit a product proposal for your contribution, and have it reviewed by the Product Working Group.
    • This process (including the steps you'll need to take) is documented here.
  • If it doesn't, simply proceed with the next step.
🔘 Provide context

To 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:

  • Dependencies

    This PR must be merged before / after / at the same time as ...

  • Blockers

    This PR is waiting for OEP-1234 to be accepted.

  • Timeline information

    This PR must be merged by XX date because ...

  • Partner information

    This is for a course on edx.org.

  • Supporting documentation
  • Relevant Open edX discussion forum threads
🔘 Get a green build

If 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 PR

Your 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:

  • The size and impact of the changes that it introduces
  • The need for product review
  • Maintenance status of the parent repository

💡 As a result it may take up to several weeks or months to complete a review and merge your PR.

jesperhodge and others added 2 commits September 2, 2026 12:08
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>
@mphilbrick211 mphilbrick211 moved this from Needs Triage to Waiting on Author in Contributions Sep 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

open-source-contribution PR author is not from Axim or 2U

Projects

Status: Waiting on Author

Development

Successfully merging this pull request may close these issues.

Mastery status lookup + learner progress models

3 participants