Skip to content

feat: add CompetencyCriterion, the criteria tree's leaf - #7

Open
jesperhodge wants to merge 2 commits into
jesperhodge/cbe-641-06-rule-profilefrom
jesperhodge/cbe-641-07-criterion
Open

feat: add CompetencyCriterion, the criteria tree's leaf#7
jesperhodge wants to merge 2 commits into
jesperhodge/cbe-641-06-rule-profilefrom
jesperhodge/cbe-641-07-criterion

Conversation

@jesperhodge

@jesperhodge jesperhodge commented Sep 9, 2026

Copy link
Copy Markdown
Owner

Part 7 of 7 for issue openedx#641. Based on part 6. Completes the criteria tree: this model's own on_delete values, the tests for them, the transitive and scope-owner cases that only exist once the tree is complete, and the tree-wide integration test all land here. There used to be a separate part 8 collecting every model's delete tests in one place; it added nothing of its own once each model's own tests moved to the PR that declares its foreign keys, so it is closed.

# Branch What it adds
1 cbe-641-01-adr ADR-0002 scope_code amendment (openedx#809)
2 cbe-641-02-deps-and-layering django-simple-history, .importlinter (#2)
3 cbe-641-03-taxonomy-overrides-org taxonomy_overrides_org, models/ package (#3)
4 cbe-641-04-criteria-group CompetencyCriteriaGroup (#4)
5 cbe-641-05-rule-payloads RuleType, validate_rule_payload (#5)
6 cbe-641-06-rule-profile CompetencyRuleProfile + seed (#6)
7 cbe-641-07-criterion CompetencyCriterion (#7)

This PR settles its own three on_delete values, and tests them itself. group and
object_tag are CASCADE: a leaf is meaningless without its group, and meaningless without
the content association it evaluates. rule_profile is RESTRICT, not PROTECT: both refuse
a direct profile delete while a criterion is assigned to it, but only RESTRICT ignores
referencing rows that the same delete operation is already removing. That is what lets a scope
owner's deletion (a taxonomy or course, cascading through part 6's CompetencyRuleProfile
foreign keys) carry its profile away instead of failing on a criterion that same delete was
about to remove anyway through this model's own group → tag → taxonomy chain. PROTECT would
abort that walk partway down. test_criterion_deletion.py covers all three, plus the transitive
tag/taxonomy cascades and the two RESTRICT payoff/residual scenarios that only exist once this
model completes the tree, and test_criteria_trees.py holds the tree-wide integration test.

What this does

Adds CompetencyCriterion, a leaf of the tree. A leaf points at one 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 check constraint enforces ADR-0002 Decision 4's invariant: either rule_profile is set and both override fields are null, or rule_profile is null and both overrides are set. Never both, never neither.

The stored rule_profile is never re-resolved at read time. Decision 4 assigns it at four named write events and stores the result. Creating a more specific profile later does not silently re-govern a criterion that already resolved to a less specific one; a test pins that, so a property or manager method that recomputed the foreign key on every read would fail rather than quietly contradicting the ADR. Computing the assignment itself is authoring-API work and is not here.

The class is named CompetencyCriterion, singular, and carries no Meta.db_table override, so the table is Django's default openedx_learning_competencycriterion. 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, and no model in src/ overrides db_table today.

Note on the AC wording. Issue openedx#641 says the resulting table is openedx_learning_competencycriteria. Django's default for this class is openedx_learning_competencycriterion, with the -on ending, so the AC's parenthetical is off by a word. The instruction it gives, add no db_table override, is what this PR follows, and the test asserts the real default name.

Reviewing this PR

test_criterion.py is ordered schema, then the either-profile-or-overrides invariant and every way to violate it, then override payload validation and the profile that is never re-resolved, then indexes and history. test_criterion_deletion.py is the sibling module for this model's own on_delete values and everything that only exists once it completes the tree. test_criteria_trees.py holds the one test that needs a wider, mixed tree to make its point: it deletes a group partway down a realistic tree and asserts the exact surviving row set, which is where a per-foreign-key test cannot help you.

The invariant is worth checking closely, because three of its four invalid states reach the database check constraint and raise IntegrityError, while the fourth, rule_type_override set with no payload, is caught earlier by save()'s payload validation and raises ValidationError. Two similar-looking mistakes therefore fail differently, and test_setting_a_rule_type_override_without_a_payload_is_rejected_by_save documents why.

Verifying

pytest tests/openedx_learning --no-cov -q                            # 99 passed
pytest tests/openedx_learning --no-cov -q --ds=mysql_test_settings
python manage.py makemigrations openedx_learning --check --dry-run   # no changes detected
lint-imports
make pii_check

By hand, the invariant, which is the one a future API can most easily break:

from openedx_learning.models import (
    CompetencyCriteriaGroup, CompetencyCriterion, CompetencyRuleProfile, CompetencyTaxonomy,
)
from openedx_tagging.models import ObjectTag, Tag

taxonomy = CompetencyTaxonomy.objects.create(name="T", export_id="t")
tag = Tag.objects.create(taxonomy=taxonomy, value="Writing")
group = CompetencyCriteriaGroup.objects.create(tag=tag)
object_tag = ObjectTag.objects.create(object_id="block-v1:x+y+z+problem+p1", taxonomy=taxonomy, tag=tag)
default = CompetencyRuleProfile.objects.get(organization=None, course=None, competency_taxonomy=None)
payload = {"op": "gte", "value": 0.8, "scale": "percent"}

CompetencyCriterion.objects.create(group=group, object_tag=object_tag, rule_profile=default)  # fine
CompetencyCriterion.objects.create(group=group, object_tag=object_tag)                        # IntegrityError: neither
CompetencyCriterion.objects.create(
    group=group, object_tag=object_tag, rule_profile=default,
    rule_type_override="Grade", rule_payload_override=payload,
)                                                                                             # IntegrityError: both
CompetencyCriterion.objects.create(group=group, object_tag=object_tag, rule_type_override="Grade")
                                                                                              # ValidationError, not IntegrityError

And that the stored profile is not re-resolved at read time:

c = CompetencyCriterion.objects.create(group=group, object_tag=object_tag, rule_profile=default)
CompetencyRuleProfile.objects.create(competency_taxonomy=taxonomy, rule_type="Grade", rule_payload=payload)
c.refresh_from_db()
c.rule_profile_id == default.pk         # True: unchanged

Refs openedx#641

jesperhodge and others added 2 commits September 11, 2026 10:18
A leaf points at one 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 check constraint enforces ADR-0002 Decision 4's
invariant: never both, never neither.

The stored rule_profile is not resolved at read time. Decision 4 assigns it at
four named write events and stores the result, so a criterion that already
resolved to a less specific profile is not silently re-governed when a more
specific one appears later. Computing that assignment is authoring-API work and
is not here.

group and object_tag cascade, per ADR-0002 Decision 7: a leaf means nothing
without the group above it or the content association it evaluates.

rule_profile is RESTRICT rather than PROTECT. Both refuse a direct profile
delete while any criterion is assigned to it, which is what makes a profile
archive-only at the ORM layer. They differ once the profile is deleted as part
of a larger operation: PROTECT raises for any referencing row it finds in the
database, so deleting a CompetencyTaxonomy would fail naming a criterion the
same operation was already about to remove, while RESTRICT ignores rows that
are themselves being deleted and lets that cascade through.

Refs openedx#641

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n test down to this PR

This model completes the criteria tree, so its own on_delete values, the
transitive tag/taxonomy cascades that only exist once it does, the two
RESTRICT-vs-PROTECT payoff/residual scenarios, and the tree-wide integration
test all belong here, not in the cross-model part 8 suite they were drafted
alongside.
@jesperhodge
jesperhodge force-pushed the jesperhodge/cbe-641-07-criterion branch from d84daef to e7d08f7 Compare September 11, 2026 14:18
@jesperhodge
jesperhodge removed this pull request from stack #11 September 11, 2026 14:29
@jesperhodge
jesperhodge added this pull request to stack #12 September 11, 2026 14:33
@jesperhodge
jesperhodge removed this pull request from stack #12 September 11, 2026 14:34
@jesperhodge
jesperhodge added this pull request to stack #13 September 11, 2026 14:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant