Skip to content

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

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

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

Conversation

@jesperhodge

@jesperhodge jesperhodge commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

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 turns
applets/cbe/models.py into a models/ package and owns migrations 0002 and 0003, so a
branch developed off main conflicts with it irreconcilably rather than merging. Because #800's
branch 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 wait
for #800.

What lands

Four models in src/openedx_learning/applets/cbe/models/learner_status.py:

Model Tracks Node foreign key
CompetencyMasteryStatus The three possible status values (lookup table)
StudentCompetencyCriteriaStatus Leaf level CompetencyCriterion
StudentCompetencyCriteriaGroupStatus Group level CompetencyCriteriaGroup
StudentCompetencyStatus Competency level oel_tagging.Tag

Plus migrations 0004_learner_status_models and 0005_seed_competency_mastery_statuses, four
read-only Django admin pages, and 24 tests.

The status ordering lives in pinned primary keys

The data migration seeds AttemptedNotDemonstrated as id 1, PartiallyAttempted as 2 and
Demonstrated as 3, and a MasteryStatus enum names them. #642 leaves the mechanism open
between that and a rank column. Pinned ids, for three reasons:

  1. The check constraint forces pinned ids regardless. The constraint restricting
    StudentCompetencyStatus has to be a single-row CHECK, because MySQL allows no subquery
    inside one, so it can only compare the row's own status_id against a literal. I verified the
    compiled SQL: Q(status__in=(2, 3)) becomes CHECK ("status_id" IN (2, 3)), no join. With the
    ids already load-bearing constants, a rank column would be a second source of truth for the
    same ordering, and two orderings can drift.
  2. The acceptance criteria cap the columns at ADR-0002 Decision 6's two for this table, id
    and status.
  3. The conditional update stays single-table. Raising a status is
    filter(user=…, criterion=…, status_id__lt=new_id).update(…), which I verified compiles to one
    UPDATE … WHERE … < with no subquery. A rank column 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 rank column can be added then and backfilled from id.
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 IntegerChoices rather than enum.IntEnum for a verified reason: Django's
migration serializer writes an IntegerChoices member as a bare integer, whereas an IntEnum
member 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.

Foreign key Value
user CASCADE
node (criterion / group / tag) PROTECT
status PROTECT

The node PROTECT values are load-bearing, not defensive. #641's four
definition-to-definition foreign keys are CASCADE, so deleting a Tag makes Django's collector
walk down into its criteria groups and then their criteria. These PROTECT values are the only
thing 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
ProtectedError when one does. PROTECT is evaluated on every row the collector reaches, not
only the row passed to delete(), which is why the transitive cases work. #675 re-implements the
same 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 StudentCompetencyCriteriaStatus as determining whether a record is
protected, 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.

user is CASCADE, not PROTECT, because PROTECT would 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 breaks the (user_id, node_id)
uniqueness the in-place-update design rests on. CASCADE rather than DO_NOTHING, which an
earlier revision of #642 offered, because DO_NOTHING does not do what its name suggests: Django
implements on_delete in Python, and MySQL builds all of these foreign keys as ON DELETE NO ACTION whatever is declared, so DO_NOTHING leaves the constraint in force and raises
IntegrityError instead of orphaning. A real orphan needs db_constraint=False, which drops
referential integrity on user_id entirely. Both behaviours were verified against a database.

Tests

24 tests. Every ProtectedError case #613 describes is here rather than in #641, because
asserting one needs a Student*Status row and this is the change that creates those tables. Each
case covers both halves, and the blocking status is spread across the three tables so all three
PROTECT values are shown firing:

Deleting Blocked by Succeeds when
oel_tagging_tag a competency status on the tag no status beneath: whole criteria tree cascades away
CompetencyCriteriaGroup at depth a leaf status two hops down no status beneath
oel_tagging_objecttag a leaf status on its criterion no status: that criterion cascades away
oel_tagging_taxonomy a group status under one of its tags

Plus: 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() and QuerySet.update(), none of which call clean();
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 history attribute 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, and makemigrations openedx_learning --check.
showmigrations confirms a single leaf, and the chain applies from an empty database with the
three 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's GeneratedField.

Two deliberate divergences from the acceptance criteria

Timestamps use manual_date_time_field(), not auto_now_add/auto_now. auto_now is
applied by DateTimeField.pre_save, which only runs on Model.save(); I verified that
QuerySet.update() carries only the values passed to it. So under auto_now, modified would go
stale on exactly the conditional-UPDATE path ADR-0004 Decision 4 mandates. This also matches the
repo's own rule: manual_date_time_field() is used in the publishing and versioning core, where
one 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, created and
modified, are unchanged and match OEP-38.

The model class is CompetencyMasteryStatus, singular, where #642 and ADR-0002 write
CompetencyMasteryStatuses. Django models are singular by convention, and #641 applies the same
resolution in naming its class CompetencyCriterion.

A third divergence from earlier revisions of this PR is now resolved: the foreign key columns
carry explicit db_column values matching ADR-0002 Decision 6 exactly, following #641's approach
of pairing an idiomatic Python attribute with the ADR's column name.

Known gaps

pii_check does not pass, for reasons that predate this feature. Every model #613 adds is
annotated, including #641's three Historical* models: none of the 20 models the coverage report
lists as uncovered belongs to openedx_learning. But the repo sits at 71.8% coverage with two
lint errors (openedx_content.Draft and openedx_content.PublishableEntityVersion are both
annotated 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_check at 100%, which cannot be reached
without annotating 20 models across oel_*, openedx_catalog, openedx_content, organizations
and 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_check below 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

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>
@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 10:45
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>
@mphilbrick211 mphilbrick211 moved this from Needs Triage to Waiting on Author in Contributions Sep 2, 2026
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>
@jesperhodge
jesperhodge force-pushed the jesperhodge/feat--642-mastery-status-models branch from 8fec250 to 9a3ce50 Compare September 3, 2026 20:46
@jesperhodge jesperhodge changed the title feat: add mastery status lookup and learner competency status models feat: add mastery status lookup and the three learner status models Sep 3, 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.

3 participants