openedx / openedx/openedx-core
Competency criteria models (authoring/definition layer)
@jesperhodge is already working on this.
Since Sep 1, 2026.
- Dominant language
- Python
- Stars
- 10
- Forks
- 32
- Avg merge
- 2d 17h
- Merged PRs (30d)
- 12
Description
Competency criteria models (authoring/definition layer)
User Story
As a course author, I want a competency's completion requirements stored as an ordered
AND/OR tree of criteria, in order to express a rule like "pass the final and either lab"
rather than a single flat threshold.
Acceptance Criteria
-
CompetencyTaxonomyhas thetaxonomy_overrides_orgboolean, defaultfalse. The model itself shipped in PR #712 without this column. -
CompetencyCriteriaGrouphas all required columns:id,parent_id(nullable self-FK),oel_tagging_tag_id,course_id(nullableForeignKeytoopenedx_catalog.CourseRun),name,ordering,logic_operator(AND/OR/null). -
openedx_catalogis added to.importlinter'sroot_packagesand placed in thesrc_layeringcontract belowopenedx_learning. Today it appears in neither, so the firstopenedx_learningtoopenedx_catalogimport would pass unexamined.lint-importspasses with no rule loosened. It is placed as an independent sibling ofopenedx_content, writtenopenedx_content | openedx_catalog, rather than as a layer of its own above or below it:layersis a strict total order, so a layer of its own would also decide the catalog-to-content direction thatsrc/openedx_catalog/ARCHITECTURE.mdrecords as undecided. #613's wording says only "belowopenedx_learning" and needs the same clarification. -
logic_operatoraccepts AND, OR or null, per ADR-0002 Decision 2, and nothing at the data layer constrains it by child count. That rule cannot hold here: a group's children need its primary key, so the group's ownclean()always sees zero children, and adding a child later calls the child'sclean(), never the parent's. Whatever rule governs null is enforced in the authoring API, when a tree is saved as a unit. - No
UniqueConstrainton(parent_id, ordering)is added. ADR-0002 requires none, and it would settle only half the ordering question: a group's children are both child groups and leafCompetencyCriteriarows, and the leaf model has noorderingcolumn at all, so sibling order among leaves would stay undefined while looking solved. -
CompetencyRuleProfilehas every column from ADR-0002 Decision 3:id,organization_id,course_id,competency_taxonomy_id,scope_code,rule_type,rule_payload,archived.rule_payloadis a validated JSON field with shape enforced perrule_type. -
scope_codeis a generated, never-null column in the format"org:X,course:Y,taxonomy:Z", non-null for the system-default row where all three scope columns are null, with a unique constraint onscope_codealone. - The
scope_codemigration is applied against MySQL, not only the SQLite used for local runs. ADR-0002 Rejected Alternative 6 records that the obvious substitute, a conditionalUniqueConstraintover the three nullable columns, compiles to a partial index that MySQL does not support: Django emits amodels.W036warning, skips the constraint, and SQLite supports partial indexes so local tests stay green. A passing local suite is not evidence here. - A check constraint enforces that at most one of
organization_id,course_idandcompetency_taxonomy_idis non-null, with a test for each rejected two-column combination. - A data migration seeds the single system-default
CompetencyRuleProfile, the row where all three scope fields are null. It is seeded witharchivedfalse,rule_type"Grade", andrule_payload{"op": "gte", "value": 0.8, "scale": "percent"}, which means "a grade of 80% or higher". Note thatvalueis a fraction between 0.0 and 1.0, not a number out of 100 (ADR-0002 Decision 3), so 80% is written0.8. This is the rule every competency criterion falls back to when nothing more specific applies, so a deployment that installs this app and adds no profiles of its own gets an 80% threshold. -
CompetencyRuleProfilescope fields are immutable after creation; editing a profile may changerule_typeandrule_payloadonly, so that criteria already resolved to a profile are never silently re-scoped. - The leaf model has all required columns, including nullable
competency_rule_profile_id,rule_type_overrideandrule_payload_override, with the same validation contract. Its class name isCompetencyCriterion, singular, matching ADR-0002 Decision 4, which calls one leaf a criterion. NoMeta.db_tableoverride is added, so the table isopenedx_learning_competencycriteria; see "The three criteria models" above for why. #613 still asks forMeta.db_table = "CompetencyCriteria"and needs the same correction. - A check constraint enforces ADR-0002 Decision 4's invariant: either
competency_rule_profile_idis set and both override fields are null, orcompetency_rule_profile_idis null and both override fields are set. Never both, never neither. A test covers each of the two invalid states. - Nothing resolves
competency_rule_profile_idat read time. ADR-0002 Decision 4 assigns it at four named write events and stores the result, and says the FK is never re-resolved dynamically at evaluation time. The assignment computation itself is API-layer work and is out of scope here. -
rule_payloadandrule_payload_overrideshape validation is enforced inclean(), reached viafull_clean(); a test must callfull_clean()with an invalid payload and assertValidationErroris raised.clean()is a convenience for the admin and for tests, not an enforcement layer: Django'sModelFormcallsfull_clean(), but DRF'sModelSerializernever does, and neither doesQuerySet.update()orbulk_create(). - The indexes from ADR-0002 Decision 5 that belong to these models are present: 1 (
CompetencyCriteriaGroup(oel_tagging_tag_id, course_id)), 2 (CompetencyCriteriaGroup(parent_id)), 4 (CompetencyCriteria(oel_tagging_objecttag_id)), 5 (CompetencyCriteria(competency_criteria_group_id)) and 9 (CompetencyRuleProfile(scope_code)). Index 9 must be unique; a plain index there fails this criterion. Index 3,oel_tagging_objecttag(object_id), is already satisfied by the existingdb_index=TrueonObjectTag.object_id(src/openedx_tagging/models/base.py), so no work is required for it here. Indexes 6, 7, 8 and 10 come from #642, which verifies all ten end to end. -
CompetencyCriteriaGroup,CompetencyCriterionandCompetencyRuleProfileeach carry auuidexternal identifier alongside the internalid, following this repo's identifier convention, so that the REST APIs and events built on them are never forced to expose an integer primary key. - The models added here are registered in
.annotation_safe_list.yml(or annotated inline) as.. no_pii:, including the three modelsdjango-simple-historygenerates:HistoricalCompetencyCriteriaGroup,HistoricalCompetencyCriterionandHistoricalCompetencyRuleProfile. Those are real Django models and the annotation scan counts them; this is the first use ofdjango-simple-historyanywhere insrc/, so nothing in this repo has hit that before. #642, as the last ticket to merge, carries themake pii_check100% coverage gate for the feature as a whole. -
django-simple-history(HistoricalRecords()) is applied toCompetencyCriteriaGroup,CompetencyCriterionandCompetencyRuleProfile. -
django-simple-historyis not applied tooel_tagging_tag,oel_tagging_taxonomy, orCompetencyTaxonomy. - Migrations are present and apply cleanly on top of #640's migration.
- No column exists on the models added here beyond those in ADR-0002 Decisions 1 through 4, the constraints, identifiers and timestamps this ticket lists, and the columns
django-simple-historygenerates. One exception, listed again under Deletions below: thearchivedcolumn that the 2026-09-01 amendment added to Decisions 2 and 4 is not added here, because #716 owns it. - All FK relationships match the ADR definitions exactly, targets included:
course_idpoints atopenedx_catalog.CourseRun.
Deletions
- The four foreign keys that carry Django's collector down the criteria tree are
CASCADE:CompetencyCriteriaGroup.tag,CompetencyCriteriaGroup.parent,CompetencyCriterion.groupandCompetencyCriterion.object_tag. These are what make ADR-0002 Decision 7's delete protection work, because thePROTECTthat blocks a delete lives on #642'sStudent*Statusforeign keys, two of which point at rows one and two levels below the tag, and Django reaches them only by walking down foreign keys markedCASCADE. -
CompetencyRuleProfile.competency_taxonomyisCASCADE, notPROTECT, so a rule profile is never the reason a taxonomy delete fails. Blocking a taxonomy delete when learner data is connected to it is an application-layer requirement, not a database constraint; see the requirement above and #613. - The other four are
PROTECT:CompetencyCriterion.rule_profile,CompetencyCriteriaGroup.course,CompetencyRuleProfile.courseandCompetencyRuleProfile.organization. NoTODOcomment is attached to any of the nine; all nine values are final. - Only the cascade half of each delete case is tested here. Every matching
ProtectedErrorcase belongs to #642, because asserting one requires aStudent*Statusrow and #642 is the ticket that creates those three tables. This ticket merges first, so the rows those assertions need do not exist yet. - Deleting an
oel_tagging_tagsucceeds and removes the whole criteria tree beneath it: everyCompetencyCriteriaGroupfor that tag, every descendant group, and everyCompetencyCriterionunder any of them. - Deleting a
CompetencyCriteriaGroupat depth succeeds and removes the target, every descendant group, and every criterion under any of them. - Deleting an
oel_tagging_objecttagsucceeds and cascades its criteria away, leaving the parent group behind. - Deleting an
oel_tagging_taxonomysucceeds and removes the criteria trees of every tag beneath it.Tag.taxonomyis alreadyCASCADEinopenedx_tagging, which is what makes the tag case above hold transitively from the taxonomy. - No
delete()override, no archive-versus-delete branch, and no deletion-lock field lands in this ticket. The cascade is declared on the foreign keys and nothing here implements deletion behavior in code. #655's approved design enforces archive-versus-delete entirely at the application layer, driven by a persisted lock flag onoel_tagging_objecttag, which changesopenedx_taggingas well as CBE. -
CompetencyRuleProfile.archivedexists as a column defaulting to false, per the ADR-0002 Decision 3 criterion above. Nothing in this ticket enforces that a profile is only archived and never deleted. #655 resolves that differently rather than deferring it: the model gets no DELETE endpoint at all, and only the single system-default row exists in MVP. - No
archivedcolumn is added toCompetencyCriteriaGrouporCompetencyCriterion. ADR-0002 Decisions 2 and 4 now list one on each, added by the 2026-09-01 amendment for #655, but #716 owns landing it. Read the "no column beyond ADR-0002 Decisions 1 through 4" criterion above with that carve-out.
Description
This is one of three tickets implementing #613's model layer, and the first of the three
to merge. 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
The three criteria models. A competency is a Tag in a competency-enabled taxonomy.
Hanging off that tag is a tree: CompetencyCriteriaGroup rows are the internal AND/OR
nodes, and CompetencyCriterion rows are the leaves. Each leaf points at an ObjectTag,
meaning one specific piece of tagged content, and takes its pass rule either from a shared
CompetencyRuleProfile or from its own inline override pair. A CompetencyRuleProfile is
a reusable set of evaluation settings scoped to exactly one of an organization, a course,
or a taxonomy, plus one system-default row scoped to none of them. The leaf keeps the
default table name because ADR-0002's heading, "CompetencyCriterion concept
(CompetencyCriteria database table)", names the domain concept the way every other
heading in that ADR does rather than instructing a rename; no model in src/ overrides
db_table today, and an unprefixed CamelCase table name would risk a collision, because
openedx-core's tables share a MySQL schema with openedx-platform's. Today
src/openedx_learning/applets/cbe/models.py is a single module holding only
CompetencyTaxonomy; this ticket turns it into a models/ package, since the three new
models form one connected structure and want a module of their own.
Deletion, and the two facts most likely to be misread. #655 fixed all nine on_delete
values on 2026-09-02, they are final, and the criteria above say why the four CASCADE
links are load-bearing rather than a relaxation. Two consequences are easy to get wrong.
First, on_delete governs deletion of the row a foreign key points at, never the row
holding the foreign key, so CompetencyCriterion.rule_profile being PROTECT does not
block a tag delete that cascades criteria away; it only stops a rule profile from being
deleted while a criterion still references it. Second, PROTECT is evaluated on every row
Django's collector reaches, not only on the row passed to delete(), which is what makes
the transitive cases work at all. One consequence of splitting this work from #642: until
#642 merges, main carries a CASCADE chain with no PROTECT at the bottom, so deleting
a tag removes the whole authored tree and nothing objects. That window is expected and
harmless, because the learner status tables do not exist yet.
Several plausible additions are excluded on purpose, worth knowing up front so they are
not added and then removed in review: no constraint tying a group's logic_operator to its
child count and no rejection of empty groups, because a group has no children at the moment
it is validated and saving a child later runs the child's clean() rather than the
parent's; no UniqueConstraint on (parent, ordering), because leaves carry no ordering
column and sibling order among them would stay undefined while looking solved; and nothing
that resolves a criterion's rule profile at read time, because ADR-0002 Decision 4 assigns
that foreign key at four named write events and stores the result. All three belong to the
authoring API, which no ticket owns yet. Relatedly, clean() is a convenience for the admin
and for tests rather than an enforcement layer, which is why this ticket's two invariants
are database check constraints instead.
Implementation specifics
- Migrations live in
src/openedx_learning/migrations/. Thecbeapplet has no
migrations package of its own. #640's0001_initialis there, so this ticket's two
migrations are0002and0003, and #642 renumbers onto them. django-simple-historyis not a declared dependency yet. It is pinned in the
compiled requirements only as a transitive dependency ofedx-organizations, and
simple_historyis absent fromINSTALLED_APPS. Add it torequirements/base.inand
register the app intest_settings.pyandprojects/dev.pybeforeHistoricalRecords()
will work.- The
Graderule payload shape is{"op": "gte", "value": 0.8, "scale": "percent"},
whereopis one ofgte,lte,eqandvalueis a fraction from 0.0 to 1.0 rather
than a number out of 100.Gradeis the onlyrule_typesupported now. - Why
taxonomy_overrides_orgships although nothing reads it. It settles a tiebreak
that cannot arise yet: when a criterion could inherit its rule from an organization-scoped
profile or from a taxonomy-scoped one, this flag decides which wins. Organization-scoped
profiles do not exist. Adding the column now avoids a later migration against a table that
by then has learner data hanging off it. - The requirement referenced by the
competency_taxonomycriterion above. Once a rule
profile can be scoped to a taxonomy, deleting that taxonomy must be blocked when learner
data is connected to it and must otherwise succeed, matching #655's approved behavior for
every other record. That guardrail is application-layer Python, is not designed yet, and is
not this ticket's work. This ticket only keeps the database from pre-empting the decision,
by leavingPROTECToffCompetencyRuleProfile.competency_taxonomy. - Two accepted cascade consequences, so neither reads as a defect in review. Deleting an
ObjectTagoutside #674 and #675 cascades its criteria away and can leave an empty parent
group behind, which is reachable only for an ungraded criterion. Deleting aTagleaves
itsObjectTagrows with a nulltag, sinceObjectTag.tagisSET_NULL, which dangles
nothing because every criterion pointing at those rows is cascaded away in the same
operation. Neither cascade is silent:django-simple-historyconnectspost_delete, so a
history_type='-'row is written for every group and criterion removed. CompetencyRuleProfile.organizationneeds no requirements change.edx-organizations
is already inrequirements/base.in, andsrc/openedx_catalog/models/catalog_course.py
already importsOrganizationforCatalogCourse.org.- Out of scope: #642's learner status models; all archive-versus-delete enforcement,
which #655 governs and which lands in #674, #675, #716, #776 and #778, with #799 closed
as superseded; the app skeleton, delivered by PR #712 for #640; and any REST API or UI
work.
Files to create and modify
New files
| File | Purpose |
|---|---|
| src/openedx_learning/applets/cbe/models/init.py | re-export the CBE models |
| src/openedx_learning/applets/cbe/models/competency_taxonomy.py | CompetencyTaxonomy, moved from models.py, plus taxonomy_overrides_org |
| src/openedx_learning/applets/cbe/models/criteria.py | CompetencyCriteriaGroup, CompetencyRuleProfile, CompetencyCriterion |
| src/openedx_learning/migrations/0002_competency_criteria.py | schema migration |
| src/openedx_learning/migrations/0003_seed_default_rule_profile.py | seeds the system-default rule profile |
Modified files
| File | Nature of modification |
|---|---|
| src/openedx_learning/applets/cbe/models.py | removed, replaced by the models/ package above |
| tests/openedx_learning/applets/cbe/test_models.py | constraint, index, validation and cascade tests |
| .importlinter | register openedx_catalog in root_packages and in the src_layering contract |
| .annotation_safe_list.yml | annotate the three new models and the three Historical* models |
| requirements/base.in | declare django-simple-history directly |
| test_settings.py | add simple_history to INSTALLED_APPS |
Context
- Parent issue: #613. ADR-0002 Decisions 1 through 5 and 7, and ADR-0003, define this model layer.
- #655 records the approved archive-versus-delete design and the nine final
on_deletevalues. - #642 adds the learner status models and carries the gates that span both tickets. This ticket
can be developed in parallel with it but must merge first. - #640, delivered by PR #712, created the app and
CompetencyTaxonomy.openedx_catalog.CourseRun
already exists in this repo. src/openedx_catalog/ARCHITECTURE.mdrecords the catalog-to-content import direction as
undecided..importlinteralready uses the sibling form for
openedx_content.applets.components | openedx_content.applets.containers.
Open Questions
- Once taxonomy-scoped
CompetencyRuleProfilerows exist, doesCompetencyCriterion.rule_profile
still wantPROTECT? Owner: whoever designs the application-layer guardrail described in
Technical Details. Not settled here; see #613.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.