Skip to content

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

Draft
jesperhodge wants to merge 1 commit 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 1 commit into
openedx:mainfrom
jesperhodge:jesperhodge/feat--642-mastery-status-models

Conversation

@jesperhodge

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

Every new foreign key is on_delete=PROTECT with a TODO(#799) comment, per #642's
instruction. This is a fail-closed placeholder, not a per-foreign-key decision, and there
are no delete tests. #799 sets the real values.

One gap worth knowing about, which I have also raised on #799: that issue's criteria scope
themselves to "FKs from Student*Status models to definition models", but these models also
carry foreign keys to the user model and to the status lookup table, which are not
definition models and which no criterion there currently covers.

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 is meant to merge after #641, and #641 is in progress with its own 0002 and 0003
migrations in this same app. This branch will need a rebase and a renumber to 0004/0005
once #641 lands. Docstrings refer to the seed migration by name rather than number so that
renumbering does not leave stale references. #641 also converts
applets/cbe/models.py into a models/ package; slice 2 moves these models into it as
models/mastery.py, which needs no migration since a model's table name comes from its app
label and class name, not its module path.

🤖 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.

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: Needs Triage

Development

Successfully merging this pull request may close these issues.

Mastery status lookup + learner progress models

2 participants