feat: add competency criteria models for CBE authoring layer - #800
feat: add competency criteria models for CBE authoring layer#800jesperhodge wants to merge 4 commits into
Conversation
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>
|
Thanks for the pull request, @jesperhodge! This repository is currently maintained by 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 approvalIf you haven't already, check this list to see if your contribution needs to go through the product review process.
🔘 Provide contextTo 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:
🔘 Get a green buildIf 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 PRYour 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:
💡 As a result it may take up to several weeks or months to complete a review and merge your PR. |
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>
Note
This PR was implemented by an AI agent (Claude Code), with a human directing the
work and reviewing the decisions. Every acceptance criterion, deviation and known gap
is spelled out below. Please review it as you would any other PR, and treat the
"Deletion behavior", "Two consequences worth knowing" and "What is not verified here"
sections as the places to look hardest.
Closes #641.
What this adds
The authoring and definition half of the CBE data model, per
ADR-0002
and ADR-0003.
Three models, one column on an existing model, two migrations, and four configuration edits.
No REST endpoints, no UI, no evaluation logic.
CompetencyCriteriaGroup: internal AND/OR nodes of the criteria tree. Nullable self-FKfor the parent, a tag FK for the competency, and a nullable
CourseRunFK that scopesevaluation windowing.
CompetencyRuleProfile: reusable evaluation defaults, scoped to at most one of anorganization, a course, or a taxonomy.
CompetencyCriterion: leaf nodes, pointing either at a rule profile or at a pair ofper-criterion overrides.
CompetencyTaxonomy.taxonomy_overrides_org: the boolean ADR-0002 Decision 1 calls for,which PR feat: provide openedx_learning djangoapp and CompetencyTaxonomy model #712 left off. Nothing reads it yet; it settles a tiebreak that cannot arise until
organization-scoped profiles exist. Shipping it now means adding those later is not a
migration against a table with learner data hanging off it.
Why
scope_codeis a generated columnAt most one profile may exist per distinct scope. A unique constraint over the three nullable
scope columns would not enforce that, because SQL never treats two
NULLs as equal: two rowsboth with
organization_id=5and the other two columns null would both be accepted. TheDjango-idiomatic fix,
UniqueConstraint(condition=Q(...)), is worse than useless here.Django compiles it to a partial index, MySQL does not support partial indexes, so Django
emits a
models.W036warning and silently skips the constraint, while SQLite does supportthem and would keep local tests green with nothing enforced. ADR-0002 Rejected Alternative 6
records this.
scope_codecollapses the scope into one always-non-null string so a plainunique constraint works identically on every backend, and a test asserts no
UniqueConstrainton the model carries a
condition, so the rejected alternative cannot creep back in.Where each rule is enforced, and why
Both structural invariants are database check constraints, not
clean()checks: at most onescope column non-null, and a criterion having either a profile or both overrides, never both
and never neither.
clean()is reached only throughfull_clean(), whichModelFormand theadmin call but which DRF's
ModelSerializer,QuerySet.update()andbulk_create()do not.Payload shape validation does live in
clean(), as the issue specifies.Scope fields are immutable after creation, because criteria store the profile id they were
assigned and never re-resolve it, so a scope change would silently re-govern every criterion
pointing at the profile. This is enforced at the instance level and covers deferred loads;
QuerySet.update()remains outside its reach, and the model docstring says so.Deletion behavior
#655 decided this on 2026-09-02. All nine values are final and none carries a
TODO.CompetencyCriteriaGroup.parentCASCADECompetencyCriteriaGroup.tagoel_tagging.TagCASCADECompetencyCriterion.groupCompetencyCriteriaGroupCASCADECompetencyCriterion.object_tagoel_tagging.ObjectTagCASCADECompetencyCriterion.rule_profileCompetencyRuleProfilePROTECTCompetencyCriteriaGroup.courseopenedx_catalog.CourseRunPROTECTCompetencyRuleProfile.courseopenedx_catalog.CourseRunPROTECTCompetencyRuleProfile.organizationorganizations.OrganizationPROTECTCompetencyRuleProfile.competency_taxonomyCompetencyTaxonomyPROTECTThe four
CASCADEvalues are not a relaxation of ADR-0002 Decision 7, and that is the partmost worth checking. Deleting a tag nobody holds mastery against has to succeed, and #655's
design forbids
openedx_taggingfrom knowing CBE exists, so the tagging-side path cannot clearthe criteria tree first.
CASCADElets the delete take the tree with it. The protection thatDecision 7 promises lives on #642's three
Student*Statusforeign keys, two of which sit one andtwo levels below the tag, and Django reaches them only by walking down
CASCADEedges. So thesefour links are what carries the collector to the
PROTECTthat enforces the guarantee. Turn anylink in that chain to
SET_NULLand a tag delete succeeds while learner statuses still exist.parentandgroupareCASCADEfor a separate reason: Django's collector looks up referencingrows in the database via
related_objects()rather than in the set it has already decided todelete, so a parent and child reached in the same batch would still trip
PROTECTand abort thewalk partway down.
The direction rule, since it is the likeliest objection to the table above.
on_deletegoverns what happens when the row a foreign key points at is deleted, and is never consulted
when the row holding it is deleted. So
rule_profilestayingPROTECTdoes not block a tagdelete that cascades criteria away; it only stops a
CompetencyRuleProfilefrom being deletedwhile a criterion references it, which is Decision 7's archive-only rule at the ORM layer.
PROTECTis evaluated on every row the collector reaches, not only the row passed todelete(),which is what makes the transitive cases work at all.
The five
PROTECTvalues:rule_profilebecause a profile is archive-only; bothcoursefieldsbecause
PROTECTmatchesopenedx_catalog's own convention (CourseRun.catalog_courseandCatalogCourse.orgare bothPROTECT) and becauseSET_NULLonCompetencyCriteriaGroup.coursewould make a course-level group read as a root group and break #675's root-group rejection;
organizationbecauseedx-organizationsexposesremove_organization(), which deactivatesrather than deletes;
competency_taxonomybecause a profile's scope is immutable soSET_NULLisforbidden, and
CASCADEwould only move the failure one hop.Nothing here implements deletion behavior in code: no
delete()override, no archive-versus-deletebranch, no deletion-lock field. That enforcement is an
openedx_taggingchange under #655's design.Two consequences worth knowing, named rather than fixed
ObjectTagdeleted outside [BE] Build endpoint for removing a Competency Criterion #674/[BE] Build endpoint for removing a Competency Criteria Group #675 can leave a persisted empty group. Its criteriacascade away, but nothing removes the now-childless
CompetencyCriteriaGroup. This is reachableonly for ungraded criteria, since once a learner holds status the
PROTECTchain blocks thedelete outright, and the endpoints in [BE] Build endpoint for removing a Competency Criterion #674/[BE] Build endpoint for removing a Competency Criteria Group #675 archive instead of deleting. Flagging it rather
than adding cleanup logic, which Competency criteria models (authoring/definition layer) #641 puts out of scope.
TagleavesObjectTagrows with a nulltag.ObjectTag.tagisSET_NULLupstream, by ADR-0006's design so cached tag text keeps rendering. This dangles nothing here,
because the criteria pointing at those
ObjectTagrows are removed in the same operation, viathe tag to group to criterion cascade.
The cascade is not silent for audit.
django-simple-historywrites ahistory_type='-'rowfor every group and criterion a cascade removes. Verified rather than assumed:
post_deleteisconnected unconditionally, and registering that receiver also stops Django's collector from
fast-deleting these models, so the signal fires for cascaded rows too.
test_group_tag_cascadeasserts it.
Deviations from the acceptance criteria
Meta.db_tableisopenedx_learning_competencycriteria, not the literal"CompetencyCriteria". The table stays plural against a singular class name, which is whatADR-0002 Decision 4 asks for, but app-prefixed and lowercase like every other table in this
repo. An unprefixed mixed-case table would be the only one of its kind here, and mixed-case
table names behave differently between macOS and Linux MySQL. Happy to change it back if
the reviewer would rather have the literal name.
models.Index. All three are plain foreign-keycolumns, which Django already indexes, so writing them out would create a second index on
each and cost write throughput for nothing.
test_adr_indexes_presentintrospects the realtables and asserts each ADR position is covered, so this can be checked rather than taken on
trust. Index 1 is explicit and index 9 is the unique constraint.
django-simple-historyis now declared inrequirements/base.in. CBE app foundation + CompetencyTaxonomy model #640 said norequirements work was needed because it appears in
base.txt, but that appearance istransitive, via
edx-organizations.setup.pybuildsinstall_requiresfrombase.in, soimporting it directly without declaring it breaks the day
edx-organizationsdrops it. Thepin is unchanged at 3.13.0.
What is not verified here
The MySQL criterion. #641 requires the
scope_codemigration to be applied against MySQL,because a green SQLite run is not evidence for it. That has not been done locally; it rides on
this repo's CI, whose
django52tox environment runs the suite againstmysql:8viamysql_test_settings.py. The two backends genuinely diverge: Django compilesConcattoCONCAT_WS('', ...)on MySQL and to||on SQLite. Please confirm CI is green beforemerging rather than treating the local results below as sufficient.
The import-linter gate covers only half of what these models import. Registering
openedx_catalogcloses theopenedx_learningtoopenedx_catalogdependency, which is what#641's criterion asks for. But
CompetencyRuleProfile.organizationadds a second new dependency,on
organizations.Organizationfromedx-organizations, which becomes a new runtime requirementof this library. That package is third party, so it is not in
root_packagesand thesrc_layeringcontract does not reach it: nothing examines that dependency at all. Saying soexplicitly rather than letting
lint-imports: 2 contracts keptread as complete coverage.Half of the transitive delete criteria belong to #642. #641 pairs each cascade case with a
"raises
ProtectedErrorwhen a learner status row exists beneath it" case. Those need #642'sStudent*Statustables, which do not exist on this branch, and #642's own criterion says they"can only run once #641's tables exist, so they belong in the slice that follows #641." The
cascade halves are all tested here; the protective halves are not stubbed or faked, and a comment
in the test file records why they are absent.
Out of scope
and a not-yet-filed
openedx_taggingticket for thearchivedanddeletion_lockedcolumns.logic_operatoron amulti-child group, and rejecting empty groups. All three are API-layer work per ADR-0002. A
group has no children at the moment it is saved, because a child row needs its parent's
primary key first, so none of them can be enforced at this layer.
Testing
25 new test functions across
test_criteria_models.pyandtest_criteria_deletion.py, expandingto 41 cases through parametrization, plus one added to
test_models.py. They cover the treeshape, every rejected scope combination, the exact
scope_codestring for all four scope shapes,uniqueness including the two-rows-same-org case a naive constraint would miss, both invalid
criterion states, nine invalid payload shapes against both models, history rows for these three
models and not for
CompetencyTaxonomy, scope immutability including the deferred-load path, theADR indexes as they exist in the database, the seeded system-default row, the
on_deletebehaviorof all nine foreign keys, and the transitive cascade cases.
The
PROTECTtests assert onProtectedError.protected_objects, not just the exception type: asingle delete can trip several protected relationships, so a bare
pytest.raiseswould not provewhich foreign key did the protecting. The cascade tests assert the referencing rows are actually
gone from the database, and that they existed beforehand.
Each of the four
CASCADEforeign keys was mutation-tested: flipping it back toPROTECTfailsexactly the tests that depend on it and no others, so none of these tests is vacuous.
Run locally against SQLite:
No
# noqa,# pylint: disable,# type: ignoreorTODOwas added anywhere in this diff.Note for anyone running
make pii_checkon this branch: it fails, at 70.1% coverage with twolint conflicts. All 20 uncovered models and both conflicts are pre-existing on
mainand incode this PR does not touch. The models added here are annotated and covered.
🤖 Generated with Claude Code