Skip to content

Mastery status lookup + learner progress models #642

Description

@jesperhodge

Description

Parent: #613. This ticket implements the "Lookup Data" and "Learner Progress Models" sections of #613: tracking each learner's actual progress toward demonstrating a competency. See #613 for full background and ADR-0002/ADR-0003.

What to build

  • CompetencyMasteryStatuses — a small lookup table of possible status values. Seed it with exactly three rows: Demonstrated, AttemptedNotDemonstrated, PartiallyAttempted. Seed these via a dedicated data migration, not via fixtures or application code — this keeps the seeding auditable and repeatable.
  • StudentCompetencyCriteriaStatus, StudentCompetencyCriteriaGroupStatus, StudentCompetencyStatus — one row per status change for a learner. These are append-only: each row only has a created timestamp (no updated field), and nothing is ever edited in place. To find a learner's current status for something, query for the latest row (created descending, then id descending as a tiebreaker) rather than looking for "the" row. Because they're append-only event logs, they do not use django-simple-history — there's no "previous version" to track, every row already is a permanent record.

Migrations (two, in order): this ticket creates four new tables (CompetencyMasteryStatuses plus the three Student*Status models), so it needs (1) a schema migration that creates those tables (e.g. 0003_...), then (2) a separate data migration that seeds the three CompetencyMasteryStatuses rows (e.g. 0004_seed_mastery_statuses.py). The seed must run after the table exists — don't fold the seed into the schema migration and don't seed a table that hasn't been created yet.

Delete protection: the foreign keys from these three status models to the criteria models (from ticket 2) must use on_delete=PROTECT. This means Django will refuse to delete a CompetencyCriteria or CompetencyCriteriaGroup row (raising ProtectedError) if any learner has a status row pointing at it — at the database-query layer, automatically, before any SQL runs. Don't implement this by overriding a model's delete() method — that approach doesn't work here because Django's bulk QuerySet.delete() (used e.g. by cascading deletes or admin bulk actions) skips instance-level delete() overrides entirely. on_delete=PROTECT is enforced at a lower level and can't be bypassed that way.

Status restriction on StudentCompetencyStatus: a learner's overall competency status should never be recorded as AttemptedNotDemonstrated — only Demonstrated or PartiallyAttempted make sense at that level. The foreign key to CompetencyMasteryStatuses itself doesn't restrict which values are allowed (any status ID is a valid FK target), so enforce this rule in the model's clean() method instead, called via full_clean().

Acceptance criteria (from #613)

These are copied verbatim from #613; only the ones this ticket is responsible for are listed. As the last ticket to merge, this ticket also owns the whole-feature gates (all 10 indexes present; make pii_check 100% coverage).

  • CompetencyMasteryStatuses is seeded with all three values: Demonstrated, AttemptedNotDemonstrated, PartiallyAttempted
  • StudentCompetencyStatus enforces a constraint limiting status to Demonstrated and PartiallyAttempted only (not AttemptedNotDemonstrated), validated in clean() / full_clean(); a test must call full_clean() with AttemptedNotDemonstrated and assert ValidationError is raised
  • All 10 indexes from Decision 5 of ADR-0002 are present
  • Delete protection is implemented via on_delete=PROTECT on FKs from Student*Status models to definition models; hard deletion is blocked at the ORM layer without requiring instance-level delete() overrides
  • All new models are registered in .annotation_safe_list.yml (or inline docstrings). Authoring/definition models use .. no_pii:. The three StudentCompetency*Status models use .. pii: / .. pii_types: id / .. pii_retirement: consumer_api. make pii_check passes with 100% coverage.
  • Learner status tables use append-only rows (only a created timestamp, no updated); no history package applied
  • No columns exist on any model that are not defined in the ADRs
  • All FK relationships match the ADR definitions exactly

Additional acceptance criteria (from splitting this out of #613)

These are not in #613 verbatim; they are needed because #613 was split into three tickets.

  • Of [BE] Implement CBE core data models (CompetencyTaxonomy, criteria, learner status) #613's "All 10 indexes from Decision 5 of ADR-0002 are present": this ticket adds indexes 6, 7, 8, and 10 (the ones scoped to the status/lookup models). Indexes 1, 2, 4, 5, 9 come from ticket 2 and index 3 is pre-existing; verify all 10 are present here as the final ticket.
  • Delete protection is verified at the ORM layer: deleting a referenced CompetencyCriteria / CompetencyCriteriaGroup raises ProtectedError, without relying on any instance-level delete() override.
  • Two migrations, in order: a schema migration creating the four new tables (CompetencyMasteryStatuses plus the three Student*Status models), then a separate data migration that seeds the three CompetencyMasteryStatuses rows. The seed must run after the tables exist.
  • Migration applies cleanly on top of ticket 2's migration ([BE] Implement CBE core data models (CompetencyTaxonomy, criteria, learner status) #613's "apply cleanly from scratch" is verified end-to-end across the three merged tickets).

Known open question, flagged in #613's comments but not resolved there — worth raising with the reviewer before or during this ticket: ADR-0002's Decision 7 says hard deletes should also be blocked for CompetencyTaxonomy, CompetencyRuleProfile, and oel_tagging_objecttag once learner status references them. The on_delete=PROTECT fields described above only protect CompetencyCriteria, CompetencyCriteriaGroup, and the tag/mastery lookups they directly point to — they don't cover those other three. Confirm with the reviewer whether that gap needs a separate mechanism in this ticket or is being deliberately deferred.

Out of scope

The criteria models themselves (ticket 2); the taxonomy model (ticket 1); any REST API or UI work.

Depends on

Ticket 2 (its on_delete=PROTECT foreign keys point into ticket 2's tables) and ticket 1 (needs the app to exist). This is the last of the three to merge.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status
    Needs additional details

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions