[BE] Enforce one root Competency Criteria Group per competency in the database
Maintainers usually reply within 1 day
@alezconsultant is already working on this.
Since Oct 16, 2026.
Assessment
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Newbie friendliness
- 22/100
Research direction
Start with CompetencyCriteriaGroup in src/openedx_learning/applets/cbe/models/criteria.py, using CompetencyRuleProfile.scope_code as the pattern, and read the lock in associate_competency_criterion() in applets/cbe/api.py. The work is a new migration 0016 with a duplicate-root check, column, backfill and constraints, plus tests in test_criteria_group.py and test_migrations.py. Done means the database rejects a second root per tag, MySQL deletes of roots and course runs still succeed, and the ADR-0002 amendment is written.
Written by the indexing model from the issue text.
Description
User Story
As a platform maintainer, I want the uniqueness of root and course-level CompetencyCriteriaGroup rows enforced by a database constraint, in order to keep the criteria tree consistent even when a code path creates groups without taking the competency's Tag row lock.
Description
CompetencyCriteriaGroup has one unconditional unique constraint on (tag, course, parent), added in #847. It keeps one course-level group per competency and course, but it doesn't constrain roots: a unique index treats each NULL as distinct, and a root has both course and parent NULL. MySQL has no partial unique indexes, so "one root per tag" can't be written as a conditional constraint either. Today one root per tag relies entirely on the select_for_update() lock that associate_competency_criterion() takes on the competency's Tag row.
CompetencyRuleProfile solved the same problem with a derived scope_code column (ADR-0002 Decision 3). We need to apply that pattern to CompetencyCriteriaGroup: a derived code that is set for root groups, null for every other group, and covered by an unconditional unique constraint, while the existing (tag, course, parent) constraint keeps protecting course-level groups.
Context
- Follow-up to #665, requested in the #847 review: https://github.com/openedx/openedx-core/pull/847#discussion_r4285673720.
- Blocked by PR #847, which adds the constraint and migration this ticket builds on.
- #674 and #675 archive groups; #717 defines what creation does with an archived group.
Acceptance Criteria
These scenarios are verifiable through the PR's tests or a Django shell against MySQL; no request to the create-criterion endpoint behaves differently.
Scenario: Reject a second root group for a competency
Given a competency already has a root CompetencyCriteriaGroup
When a second root CompetencyCriteriaGroup is saved for the same competency, outside the create-criterion flow and without the competency's lock
Then the database rejects it
And the competency still has exactly one root group
Scenario: Reject a second root group when the existing root is archived
Given a competency's only root CompetencyCriteriaGroup is archived
When a second root CompetencyCriteriaGroup is saved for the same competency
Then the database rejects it
And the archived root is unchanged
Scenario: Reject a second course-level group for the same competency and course
Given a competency has a course-level CompetencyCriteriaGroup for Course X, live or archived
When a second course-level group for the same competency and Course X is saved, individually or in a bulk insert
Then the database rejects it
Scenario: Archive a root or course-level group with no extra step
Given a competency has a live root and a live course-level group for Course X
When either group is archived, individually or in a bulk update that only marks it archived
Then the archive succeeds
And a second root, or a second course-level group for Course X, is still rejected
Scenario: Reactivate an archived root
Given a competency's only root CompetencyCriteriaGroup is archived
When the root is reactivated
Then the reactivation succeeds
And the reactivated root is still the competency's only root group
Scenario: A hard-deleted root frees its place
Given a competency has a root CompetencyCriteriaGroup with course-level and leaf groups under it
When the root is hard-deleted
Then the root and all of its descendant groups are deleted
And a new root CompetencyCriteriaGroup can then be saved for that competency
Scenario: Delete a course run that has course-level groups
Given a course run has course-level CompetencyCriteriaGroups with leaf groups under them
When the course run is deleted
Then the deletion succeeds, and the course-level groups and their leaves are deleted with it
Scenario: Courses and leaves are not limited
Given a competency has a root CompetencyCriteriaGroup
When course-level groups are saved for several different courses, and several leaf groups, live or archived, are saved under one course-level group
Then every one of them is saved
ADR-0002 is amended by a person to describe the new rule and how it is enforced.
Technical Details
This is a suggested approach, not the source of truth. The User Story, Description, and Acceptance Criteria define what must be true when the work is done.
Summary
CompetencyCriteriaGroup gets a scope_code column that is set only for root groups, computed in save() the way CompetencyRuleProfile computes its own, and covered by an unconditional unique constraint. An archived root keeps its code, so it keeps blocking a second root, which matches #717 reactivating archived roots in place. The (tag, course, parent) constraint from #847 stays and keeps protecting course-level groups on every write path. One migration checks existing data for duplicate roots before changing anything, then adds and backfills the column.
Approach
- In
CompetencyCriteriaGroup(src/openedx_learning/applets/cbe/models/criteria.py), addscope_code = CharField(max_length=255, null=True, editable=False)and a_compute_scope_code()that returnsf"tag:{tag_id},course:"for a root (parentis null) andNonefor every other group. The value does not depend onarchived. The"tag:…,course:"format matches the profile's and leaves room to cover course-level groups later if product decides archived groups should be replaceable. - Add a
save()that setsscope_codefrom_compute_scope_code()and adds"scope_code"toupdate_fieldswhen the caller passes them. It does not need the profile'sfull_clean()call. - Add
UniqueConstraint(fields=["scope_code"])andCheckConstraint(condition=Q(scope_code__isnull=True) | Q(parent__isnull=True)), so only a root can hold a code. Keep the(tag, course, parent)constraint unchanged. The check must stay one-directional ("a code implies a root"), not "every root has a code": on MySQL, Django'sCASCADEfirst sets the nullableparentandcoursekeys of the rows it is about to delete to NULL, so the reverse direction would make every child look like a root without a code and reject deleting a root. - Change
historytoHistoricalRecords(excluded_fields=["scope_code"]), as the profile does. - In migration
0016_criteria_group_scope_code.py, run a read-onlyRunPythonfirst that raises, naming the tag and group ids, if any tag has more than one root, live or archived. Then add the column, backfill it for roots with a secondRunPython(the historical model has no customsave(), as0005_seed_default_rule_profile.pynotes), and add both constraints. The duplicate check comes first because MySQL commits each schema change immediately, so an error raised after the column is added would leave it in place and the migration unrecorded. - In
associate_competency_criterion()(src/openedx_learning/applets/cbe/api.py), keep the Tag row lock and_create_leaf_group()as they are, but rewrite the comment on the lock: one root per tag is now enforced by the database, and the lock stays because it serializes the duplicate-association check. A request withoutgroup_idcreates a fresh leaf every time, so no unique index can catch two concurrent criteria for the same content in different leaves. After this ticket, every rule on the stored tree is enforced by the database, and the lock only serializes that request-level check: the same content may legitimately sit in two explicitly named groups, so the data itself can't forbid it.
bulk_create() skips save(), so a root inserted in bulk would get no code and no protection. No code creates roots in bulk today; #694's course copy reuses the existing root. Archiving needs no change: scope_code does not depend on archived, so the bulk archive in #675 and the root reactivation in #717 work as written.
Tests must cover a second root created directly without the lock, next to a live root and next to an archived one; deleting a root and a course run that have descendants on MySQL; and the migration's duplicate check and backfill over roots, course-level groups, leaves and archived rows. Existing tests that create two roots for one tag, such as the logic_operator test in test_criteria_group.py, need a second tag.
Out of scope: freeing an archived group's place for a replacement (#717 keeps archived groups permanent for MVP), and removing the Tag row lock.
Files to create and modify
| File | Change |
|---|---|
| src/openedx_learning/applets/cbe/models/criteria.py | modified: scope_code, _compute_scope_code(), save(), constraints, history exclusion |
| src/openedx_learning/migrations/0016_criteria_group_scope_code.py | new: duplicate check, column, backfill, constraints |
| src/openedx_learning/applets/cbe/api.py | modified: the comment on the Tag row lock |
| tests/openedx_learning/applets/cbe/test_criteria_group.py | modified: root constraint tests, and existing tests that create two roots for one tag |
| tests/openedx_learning/test_migrations.py | new: duplicate check and backfill |
Additional context
CompetencyRuleProfile.scope_codeinsrc/openedx_learning/applets/cbe/models/criteria.pyis the precedent; ADR-0002 Decision 3 and Rejected Alternative 6 (docs/openedx_learning/decisions/0002-competency-criteria-model.rst) explain it.- The ADR-0002 amendment covers the new column in Decision 2's field list and the new constraint in Decision 5's index list. It should also correct Decision 2's
course_idline, which says every group in a course subtree carries the course; the code leaves a leaf's course empty, and this ticket relies on that. tests/openedx_tagging/test_migrations.pyshows thedjango_test_migrationsmigratorfixture this repo uses to test a backfill.tests/openedx_learning/applets/cbe/test_criteria_group_deletion.pydeletes a course run and a root with descendants, which guards step 3.
- Dominant language
- Python
- Stars
- 10
- Forks
- 33
- Avg merge
- 2d 23h
- Merged PRs (30d)
- 22
Getting set up
- No Dockerfile or Docker Compose file
- No pull request template
- Read the contributing 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.
More from openedx/openedx-core
-
Difficulty 2/5 1-3 hours Newbie friendliness 75/100
openedx/openedx-core#831 ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 85/100
openedx/openedx-core#827 ·
Maintainers usually reply within 1 day
-
Implement devstack-only support for viewing Assets in the content libraries UI [Optional] [FC-0138]Open
Difficulty 4/5 3-5 days Newbie friendliness 45/100
openedx/openedx-core#891 ·
Maintainers usually reply within 1 day
-
Implement shared assets in openedx_content [FC-0138]Possibly taken @bradenmacdonald claimed this 1 day ago. Open
openedx/openedx-core#890 · 1 assignee ·
Maintainers usually reply within 1 day
-
[Bug] Competency Management page: the course panel doesn't match the competency tree's height and grows with its contentPossibly taken @AShatsila claimed this 4 days ago. Open
openedx/openedx-core#873 · 1 assignee ·
Maintainers usually reply within 1 day
All issues in openedx/openedx-core
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
-
[Device Support Request] HOBEIAN ZG-204ZL (Tuya PIR motion sensor with illuminance)Possibly taken @lenucksi claimed this today. Open
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
zigpy/zha-device-handlers#5493 ·
Maintainers usually reply within 3 days
-
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
drevendev/BeautifulRepo#42 ·
Maintainers usually reply within 1 day
-
enhancement good first issue
Difficulty 2/5 1-3 hours Newbie friendliness 84/100
writerslogic/holographic-memory#93 ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 66/100
antoine-tena/kiln#47 ·
Maintainers usually reply within 1 day