Skip to content

feat: settle delete behavior for the competency criteria models - #8

Closed
jesperhodge wants to merge 4 commits into
jesperhodge/cbe-641-07-criterionfrom
jesperhodge/cbe-641-08-delete-behavior
Closed

feat: settle delete behavior for the competency criteria models#8
jesperhodge wants to merge 4 commits into
jesperhodge/cbe-641-07-criterionfrom
jesperhodge/cbe-641-08-delete-behavior

Conversation

@jesperhodge

@jesperhodge jesperhodge commented Sep 9, 2026

Copy link
Copy Markdown
Owner

Part 8 of 8 for issue openedx#641. Based on part 7. Parts 4, 6, and 7 each declare their own on_delete values now, as they add the foreign key that value governs. This PR adds no on_delete value of its own; it adds the tests that exercise those decisions together, one cross-model limitation those decisions produce, and the tests pinning the MySQL collector behavior CompetencyRuleProfile.scope_code's design depends on.

# 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)
8 cbe-641-08-delete-behavior this PR — the cross-model delete tests, one known limitation, and the MySQL-collector tests

Where each decision lives

Each of the nine foreign keys below is declared, with its real on_delete value, in the part that adds it. This PR adds none of them; it is a reference for reading the tests that follow.

Foreign key Value Declared in
CompetencyCriteriaGroup.parent CASCADE part 4 (#4)
CompetencyCriteriaGroup.tag CASCADE part 4 (#4)
CompetencyCriteriaGroup.course CASCADE part 4 (#4)
CompetencyRuleProfile.organization PROTECT part 6 (#6)
CompetencyRuleProfile.course CASCADE part 6 (#6)
CompetencyRuleProfile.competency_taxonomy CASCADE part 6 (#6)
CompetencyCriterion.group CASCADE part 7 (#7)
CompetencyCriterion.object_tag CASCADE part 7 (#7)
CompetencyCriterion.rule_profile RESTRICT part 7 (#7)

Each part above explains its own values and, where relevant, the AC deviations and open questions attached to them. on_delete expresses containment, not protection: it governs deletion of the row a foreign key points at, never the row holding it. The seven cascading edges are how Django's collector walks down the tree, and they are load-bearing rather than a relaxation: openedx#642 puts PROTECT on the learner status tables one and two levels below the tag, and the collector reaches those only along edges marked CASCADE.

One deviation from openedx#641's acceptance criteria that belongs to this PR

ProtectedError tests that AC 27 does not ask for. AC 27 says only the cascade half is tested here, because the ProtectedError half needs openedx#642's tables. This PR tests three ProtectedError cases anyway: an organization with a scoped profile, a profile referenced by a criterion, and the known limitation below. None needs a status row, so they cost nothing and pin real behavior. Additive, not a violation.

One known limitation, pinned rather than worked around

Deleting a CompetencyTaxonomy whose taxonomy-scoped profile is assigned to a CompetencyCriterion raises ProtectedError naming the criterion, not the profile actually being cascaded away, because PROTECT checks the database rather than the collector's own pending set. That criterion would also be cascade-deleted in the same operation through the tag chain, so the failure is spurious.

It is unreachable in this phase: no code path creates a taxonomy-scoped profile. ADR-0002 Decision 7 records the fix for when scoped profiles are built, a fifth reassignment event on Decision 4. test_taxonomy_delete_blocked_by_its_scoped_profile_names_the_criterion_not_the_profile pins the current behavior so that changing it later is deliberate.

Reviewing this PR

test_criteria_deletion.py opens with the table above, then works through each model's foreign keys, the transitive cases openedx#641 requires, the known limitation, and finally the MySQL collector path. test_criteria_trees.py holds the two integrative tests, which are where a per-foreign-key test cannot help you:

  • test_deleting_a_middle_group_removes_its_subtree_but_leaves_the_rest_of_the_tree_untouched builds a tree with a surviving sibling branch and a mix of profile-assigned and override-governed criteria, then asserts the exact surviving row set. A test that only checks "the deleted branch is gone" cannot distinguish a correct cascade from one that over-deletes into a sibling.
  • test_object_tag_delete_leaves_a_childless_criteria_group_behind pins an accepted outcome, not a bug: CompetencyCriteriaGroup never references ObjectTag, so nothing gives the collector a reason to reach the group, and it is left inert and childless.

The three ..._under_mysql_collector_semantics tests monkeypatch can_defer_constraint_checks to False. Without that, the code path they exercise never runs on SQLite and they pass against broken and correct code alike. That path is also why CompetencyRuleProfile.scope_code is a plain column rather than a GeneratedField, reasoned through in part 6 (#6).

What is deliberately not here

Every "raises ProtectedError because a learner status row exists" case. Those need openedx#642's three Student*Status tables, and nothing here stubs or fakes a status model to stand in. Until openedx#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.

Also not here: any delete() override, archive-versus-delete branch, or deletion-lock field. openedx#655 governs that and it lands in openedx#674, openedx#675, openedx#716, openedx#776 and openedx#778.

Verifying

pytest tests/openedx_learning --no-cov -q                              # 98 passed
pytest tests/openedx_learning --no-cov -q --ds=mysql_test_settings
python manage.py makemigrations openedx_learning --check --dry-run     # no changes detected

By the time part 7 lands, every one of the nine values above is already real; there is nothing left "undecided" going into this PR. What this PR adds is the cross-model and edge-case coverage a per-field test cannot give you, such as the two integrative tests above and the known limitation below.

Refs openedx#641

jesperhodge and others added 4 commits September 11, 2026 09:27
ADR-0002 Decision 3 stores an evaluation rule as a rule_type plus a JSON
rule_payload whose shape that type defines, rather than as fixed op/value/scale
columns, so a future rule type can add its own fields without a migration. The
cost of JSON is that nothing enforces the shape, so this adds the validator the
two criteria models will call from clean().

Grade is the only supported type. Its value is a fraction from 0.0 to 1.0, not
a number out of 100, which is the mistake an author is most likely to make, so
the out-of-range message names the convention rather than only rejecting the
value.

RuleType declares exactly the types that have a payload spec, so a type can
never be offered as a choice without being saveable.

Refs openedx#641

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A reusable evaluation rule scoped to at most one of an organization, a course
or a taxonomy, plus the one row scoped to none of them, which is the rule every
criterion falls back to. A deployment that adds no profiles of its own gets an
80% threshold. See ADR-0002 Decision 3.

Uniqueness per scope cannot be a plain constraint over the three nullable scope
columns, because SQL never treats two NULLs as equal, and it cannot be a
conditional constraint either, because MySQL has no partial unique indexes and
Django silently skips creating one there. So the scope collapses into a derived
scope_code column with one unconditional unique constraint. scope_code goes
null while a profile is archived, which frees that scope for a replacement; the
three scope columns are never cleared, so nothing is lost.

Scope is immutable after creation, so criteria already resolved to a profile
are never silently re-scoped.

Deleting a scope owner takes the profile scoped to it, so course and
competency_taxonomy cascade, per ADR-0002 Decision 7. organization does not: an
Organization is not a competency definition record, and edx-organizations
retires one by clearing its active flag rather than deleting the row, so PROTECT
there refuses a delete that should not be happening.

Refs openedx#641

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
Each model now declares its own on_delete values, so what is left to prove is
the behavior they produce together, which no single model's tests can reach:
that deleting a Tag or a Taxonomy takes the whole authored tree with it, that a
scope owner's deletion carries its profile away, and that a direct profile
delete is still refused.

Two cases turn on RESTRICT rather than PROTECT on CompetencyCriterion.
rule_profile. Deleting a CompetencyTaxonomy whose scoped profile is assigned to
a criterion now succeeds, because the same operation is already deleting that
criterion through the tag chain; PROTECT raised there, naming a criterion that
was about to be removed anyway. Deleting a CourseRun is still refused when the
criterion assigned to its scoped profile sits in a tree with no course scope,
which Decision 4 permits, because that criterion really would be left pointing
at a deleted profile.

The deletion paths also run under MySQL's collector semantics while still on
SQLite, by setting can_defer_constraint_checks to false, so the pre-delete
nulling of a nullable cascading foreign key is exercised in the fast local suite
rather than only in CI.

Refs openedx#641

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jesperhodge
jesperhodge force-pushed the jesperhodge/cbe-641-07-criterion branch from a28a706 to e5c161c Compare September 11, 2026 13:27
@jesperhodge
jesperhodge force-pushed the jesperhodge/cbe-641-08-delete-behavior branch from bfc9896 to f9c8ae4 Compare September 11, 2026 13:27
@jesperhodge
jesperhodge force-pushed the jesperhodge/cbe-641-07-criterion branch from e5c161c to d84daef Compare September 11, 2026 14:05
@jesperhodge

Copy link
Copy Markdown
Owner Author

Closing this one. Every test here turned out to only need the model(s) that already exist by the time it could be written: the MySQL-collector tests split between whichever model's course/competency_taxonomy foreign key they exercise (parts 4 and 6), and everything needing CompetencyCriterion — including the transitive tag/taxonomy cascades, the two RESTRICT payoff/residual scenarios, and the one tree-wide integration test — moved to part 7, which is what completes the tree.

So there was nothing left that actually needed a PR of its own on top of part 7. All 21 tests now live in:

Part 7 is now the top of the stack. Nothing in this branch is lost — it's superseded, not discarded.

@jesperhodge
jesperhodge deleted the jesperhodge/cbe-641-08-delete-behavior branch September 11, 2026 14:18
@jesperhodge
jesperhodge removed this pull request from stack #11 September 11, 2026 14:29
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