feat: add CompetencyRuleProfile and seed the system default - #6
Open
jesperhodge wants to merge 2 commits into
Open
feat: add CompetencyRuleProfile and seed the system default#6jesperhodge wants to merge 2 commits into
jesperhodge wants to merge 2 commits into
Conversation
jesperhodge
force-pushed
the
jesperhodge/cbe-641-06-rule-profile
branch
from
September 9, 2026 21:26
7a91f62 to
aa38ceb
Compare
This was referenced Sep 9, 2026
jesperhodge
force-pushed
the
jesperhodge/cbe-641-06-rule-profile
branch
from
September 9, 2026 21:48
aa38ceb to
26237d3
Compare
jesperhodge
force-pushed
the
jesperhodge/cbe-641-06-rule-profile
branch
from
September 10, 2026 03:03
26237d3 to
59fb62f
Compare
jesperhodge
force-pushed
the
jesperhodge/cbe-641-06-rule-profile
branch
from
September 10, 2026 19:03
59fb62f to
0047a13
Compare
jesperhodge
added this pull request to stack #11
September 11, 2026 13:17
jesperhodge
force-pushed
the
jesperhodge/cbe-641-06-rule-profile
branch
from
September 11, 2026 13:27
0047a13 to
d783944
Compare
jesperhodge
force-pushed
the
jesperhodge/cbe-641-06-rule-profile
branch
from
September 11, 2026 14:02
d783944 to
98a6bfa
Compare
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>
These six tests only need CompetencyRuleProfile, so they belong with the on_delete values this PR already declares, not in the cross-model part 8 suite they were drafted alongside.
jesperhodge
force-pushed
the
jesperhodge/cbe-641-06-rule-profile
branch
from
September 11, 2026 14:18
98a6bfa to
e5fa9a1
Compare
jesperhodge
removed this pull request from stack #11
September 11, 2026 14:29
jesperhodge
added this pull request to stack #12
September 11, 2026 14:33
jesperhodge
removed this pull request from stack #12
September 11, 2026 14:34
jesperhodge
added this pull request to stack #13
September 11, 2026 14:34
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part 6 of 7 for issue openedx#641. Based on part 5. This is the largest part of the stack, because
scope_codeand scope immutability are one design and splitting them would leave a reviewer holding half of it.cbe-641-01-adrscope_codeamendment (openedx#809)cbe-641-02-deps-and-layering.importlinter(#2)cbe-641-03-taxonomy-overrides-orgtaxonomy_overrides_org,models/package (#3)cbe-641-04-criteria-groupCompetencyCriteriaGroup(#4)cbe-641-05-rule-payloadsRuleType,validate_rule_payload(#5)cbe-641-06-rule-profileCompetencyRuleProfile+ seed (#6)cbe-641-07-criterionCompetencyCriterion(#7)What this does
Adds
CompetencyRuleProfile, a reusable evaluation rule scoped to at most one of an organization, a course or a taxonomy, plus the single row scoped to none of them. That last row is the rule every 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. A data migration seeds it withGrade >= 0.8.The one design worth reading carefully:
scope_codeAt most one profile may exist per distinct scope. That cannot be a plain unique constraint over the three nullable scope columns, because SQL never treats two NULLs as equal, so two rows both with
organization_id=5and the other two null would not collide. It cannot be a conditional constraint either: that compiles to a partial index, MySQL does not support partial indexes, and Django raises only a non-fatalmodels.W036warning and silently skips creating the constraint there, while SQLite does support them and would hide the gap in a local run. ADR-0002 Rejected Alternative 6 records this.So the scope collapses into one derived
scope_codecolumn,"org:X,course:Y,taxonomy:Z"with a blank segment per null, carrying a single unconditional unique constraint. It goes null while a profile is archived, which is what frees the scope for a replacement, using the same NULL rule that made a plain constraint useless. The three scope columns are never cleared, so nothing is lost and an archived profile stays restorable, which answers the question you raised on openedx#800 about whether archiving discards the scope. It does not.There is a second reason
scope_codeis a plain column written insave()rather than aGeneratedField, and it is about Django's delete collector: a generated column would recompute from a foreign key the collector nulls mid-cascade, and collide with whichever row already holds the resulting blank scope.test_rule_profile_deletion.pypins this under MySQL's non-deferred constraint semantics, reproduced on SQLite by monkeypatchingcan_defer_constraint_checks.Scope is immutable after creation. Editing may change
rule_type,rule_payloadandarchivedonly, so criteria already resolved to a profile are never silently re-governed.clean()compares against what is actually persisted rather than a value cached at load time, so a.only()/.defer()load cannot bypass it; there is a test for that. It does not coverQuerySet.update()orbulk_create(), which never build a model instance, which is why the invariants that matter are database check constraints rather thanclean()alone.Reviewing this PR
test_rule_profile.pyis sectioned so each section is one claim: schema, the at-most-one-scope rule,scope_codeand archiving, that the payload validator is actually wired intosave(), scope immutability, then index 9, history and the seeded row. Payload shapes are covered exhaustively and without a database in part 5; the tests here only prove a model save reaches that validator.test_rule_profile_deletion.pyis the sibling module for this model's ownon_deletevalues.Verifying
Run this suite against MySQL, not only SQLite. Issue openedx#641 asks for it specifically, because the
scope_codeconstraint is the one thing SQLite cannot tell you the truth about:By hand, the archive-and-replace cycle is the most interesting behavior:
Refs openedx#641