Skip to content

Mastery status lookup + learner progress models #642

Description

@jesperhodge

Mastery status lookup + learner progress models

User Story

As a learner, I want my current mastery status recorded for each competency, criteria
group, and individual criterion, in order to see which requirements I have already met and
which are still outstanding.

Acceptance Criteria

  • The three mastery status values, AttemptedNotDemonstrated, PartiallyAttempted and Demonstrated, exist and their order is available to the database, so that raising a status can be written as one conditional UPDATE rather than a read followed by a write. A test asserts that a write of a lower value against a higher stored value changes no row, using a single statement.
  • StudentCompetencyStatus rejects AttemptedNotDemonstrated and accepts only Demonstrated and PartiallyAttempted. The rejection holds on every write path, including QuerySet.update() and bulk_create(), which never call clean(). Tests cover a direct save and a bulk write.
  • Learner status rows are updated in place, one row per learner and node under a unique constraint, per ADR-0003 Decision 5 and ADR-0002 Decision 6, which lists created and modified on all three tables. Each table carries both created (auto_now_add=True) and modified (auto_now=True). No history package is applied. [BE] Implement CBE core data models (CompetencyTaxonomy, criteria, learner status) #613 cites this as "ADR-0003 Decision 5 as amended on 2026-07-27"; that changelog entry no longer exists, because ADR-0003 Decision 5 was rewritten again afterwards. The requirement is unchanged, only the citation.
  • No monotone-write logic and no staff-edit path land here. The models accept any status value the caller writes; the rules that decide which writes are allowed, that an automatic write may raise a status but never lower it and that a staff correction may lower one, are enforced in the API layer. Those two rules now live only in ADR-0004 Decisions 4 and 6. [BE] Implement CBE core data models (CompetencyTaxonomy, criteria, learner status) #613 also attributes them to ADR-0003 Decision 5, which no longer contains them.
  • The indexes from ADR-0002 Decision 5 that belong to these models are present and unique: 6 (StudentCompetencyCriteriaStatus(user_id, competency_criteria_id)), 7 (StudentCompetencyCriteriaGroupStatus(user_id, competency_criteria_group_id)), 8 (StudentCompetencyStatus(user_id, oel_tagging_tag_id)) and 10 (CompetencyMasteryStatuses(status)). All four are unique; a plain index in any of those positions fails this criterion. For 6, 7 and 8 the uniqueness is not a performance detail: it is what makes "one row per learner and node" true, which is the precondition for the in-place updates above.
  • The models added here are registered in .annotation_safe_list.yml (or annotated inline) as .. no_pii:. Each of the three StudentCompetency*Status models stores a user foreign key and a status value and no personal data of its own, which is how every existing openedx-core model with a user foreign key is annotated, openedx_content.PublishableEntity and Collection among them. pii_retirement: consumer_api is not used, because it asserts a consumer-facing retirement API that openedx-core does not have.
  • No column exists on the models added here beyond those in ADR-0002 Decision 6, plus the constraints and the created and modified timestamps this ticket lists.
  • All FK relationships match the ADR definitions exactly, targets included: the learner user_id points at settings.AUTH_USER_MODEL rather than auth.User, with migrations.swappable_dependency declared in the migration, so that deployments with a swapped user model still work.

Deletions

  • The foreign key from each of the three status models to its definition row (CompetencyCriterion, CompetencyCriteriaGroup, or oel_tagging_tag) is PROTECT, with no TODO comment attached. Two of the three point into Competency criteria models (authoring/definition layer) #641's tables and the third points into openedx_tagging. This is the mechanism that stops Competency criteria models (authoring/definition layer) #641's CASCADE chain, so it is load-bearing rather than defensive.
  • The user foreign key on all three models is CASCADE. StudentCompetencyStatus.tag and the status foreign key to the mastery status lookup table are PROTECT.
  • This ticket owns every ProtectedError case in [BE] Implement CBE core data models (CompetencyTaxonomy, criteria, learner status) #613, including the ones Competency criteria models (authoring/definition layer) #641's prose describes, because asserting one needs a Student*Status row and this is the ticket that creates those three tables. Competency criteria models (authoring/definition layer) #641 tests only the cascade half of each case. The transitive cases are tested, not only the direct ones, since PROTECT is evaluated on every row the collector reaches rather than only on the row passed to delete().
  • Deleting an oel_tagging_tag with a status row anywhere beneath it raises ProtectedError. Deleting one with no status rows beneath it still succeeds and removes the whole criteria tree.
  • Deleting a CompetencyCriteriaGroup at depth with a leaf status row anywhere beneath it raises ProtectedError, and deleting one with no status rows beneath it succeeds.
  • Deleting an oel_tagging_objecttag whose criterion has a leaf status row raises ProtectedError, and deleting one whose criterion has none succeeds and cascades that criterion away.
  • Deleting an oel_tagging_taxonomy raises ProtectedError when a status row exists beneath any of its tags. Tag.taxonomy is already CASCADE in openedx_tagging, so the collector reaches every tag beneath the taxonomy and the tag case above holds transitively.
  • Deleting a user row removes that user's status rows across all three models, and a test covers it.
  • No delete() override, no archive-versus-delete branch, and no deletion-lock field lands in this ticket. Nothing here implements deletion behavior in code. [Arch] Implementation approach for competency data delete/edit guardrails #655's approved design enforces archive-versus-delete entirely at the application layer, driven by a persisted lock flag on oel_tagging_objecttag, which changes openedx_tagging as well as CBE.

Whole-feature gates

Description

This is one of three tickets implementing #613's model layer, and the last of the three to
merge, which is why it also carries the three gates that span the whole feature. Every
criterion above is one of #613's, or one of #613's narrowed to this ticket's models.
Nothing here is additional to the parent.

Technical Details

Background and a suggested approach, not the source of truth. The Acceptance Criteria
above define what must be true when the work is done; this section exists so an
implementer does not have to rediscover the surrounding context first.

In short

What these tables hold, and where the code goes. CompetencyMasteryStatuses is a small
lookup of the three possible status values. The other three tables record one learner's
mastery at the leaf, group, and competency levels of the tree #641 defines. They hold one
row per learner and node, updated in place, so finding a learner's current status is a
lookup of a single row rather than a query for the most recent of several. Earlier drafts
of this ticket described them as append-only; ADR-0003 Decision 5 has since been rewritten
and no longer does, on grounds of scale and because no pilot partner needs status history
for MVP. #641 turns src/openedx_learning/applets/cbe/models.py into a models/ package;
put these four models in a new models/learner_status.py inside it and export them from
models/__init__.py. Do not add a top-level models.py: after #641 that path is a
directory, and a branch still treating it as a file conflicts irreconcilably rather than
merging.

Why the status ordering has to be visible to the database. ADR-0004 Decision 4 says an
automatic update stores whichever is higher, the value already stored or the newly computed
one. Written as read, then compare in Python, then write, two concurrent tasks can both
read the old value and the later write lowers what the earlier one raised. Written as a
single UPDATE ... WHERE current_status < new_status that race cannot happen, and a
database can only make that comparison if the ordering lives in a column it can sort rather
than in a Python constant. Whether you express that as a rank column or as deliberately
ordered primary keys is your call. This ticket only has to make the comparison expressible;
the rule about which writes are allowed is API-layer work and is out of scope.

Deletion, beyond what the criteria already state. The user foreign key is CASCADE
because PROTECT there would let this library veto User.delete() platform-wide from code
in openedx-platform that has no reason to know CBE rows exist, and because a learner status
row is a derived fact about that learner. SET_NULL was never a candidate: a null user_id
would break the (user_id, node_id) uniqueness the whole in-place-update design rests on.
Separately, the three PROTECT values into the definition tables are deliberately stricter
than the predicate the application layer uses. ADR-0002 Decision 7, as amended on
2026-09-01, names the leaf table as the single table that determines whether a record is
protected and treats the two roll-up tables as derived from it. The database makes no such
distinction. A roll-up row with no leaf row beneath it should never occur, but if one ever
does, the delete fails with ProtectedError rather than succeeding, and failing closed is
the right default for a backstop.

Implementation specifics

Files to create and modify

New files

File Purpose
src/openedx_learning/applets/cbe/models/learner_status.py CompetencyMasteryStatuses and the three Student*Status models
src/openedx_learning/migrations/0004_learner_status.py schema migration
src/openedx_learning/migrations/0005_seed_mastery_statuses.py seeds the three status rows in rank order

Modified files

File Nature of modification
src/openedx_learning/applets/cbe/models/init.py export the four new models
tests/openedx_learning/applets/cbe/test_models.py constraint, index and ProtectedError tests, including the transitive cases
.annotation_safe_list.yml annotate the four new models as no_pii
Context

Open Questions

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions