Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 32 additions & 9 deletions docs/openedx_learning/decisions/0002-competency-criteria-model.rst
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,14 @@ Decision
This new database table will have the following columns:

1. ``id``: unique primary key
2. ``parent_id``: The ``CompetencyCriteriaGroup.id`` of the parent group. Null means this is a root group.
3. ``oel_tagging_tag_id``: The ``oel_tagging_tag.id`` for the competency represented by this criteria tree.
4. ``course_id``: Nullable foreign key for the course that scopes this criteria tree. Within a course-scoped subtree, every group carries the same ``course_id``, not only its topmost group; immutable after creation. Root groups always have ``course_id`` null.
2. ``parent_id``: The ``CompetencyCriteriaGroup.id`` of the parent group. Null means this is a root group. Immutable after creation.
3. ``oel_tagging_tag_id``: The ``oel_tagging_tag.id`` for the competency represented by this criteria tree. Immutable after creation.
4. ``course_id``: Nullable foreign key for the course that scopes a course-scoped subtree. Only the course-level group carries it; the groups below it leave ``course_id`` null and belong to the course through that ancestor. Immutable after creation. Root groups always have ``course_id`` null.
5. ``name``: string
6. ``ordering``: Indicates evaluation sequence number for this criteria group. This defines deterministic evaluation order for siblings during read-time evaluation and event-driven recomputation, and enables short-circuit evaluation.
7. ``logic_operator``: Either “AND” or “OR” or null. This determines how children are combined at a group node ("AND" or "OR").
8. ``archived``: Boolean, defaults to false. Set instead of deleting a group once a student status exists for it. Archived groups are hidden from authoring and new associations but remain queryable, so existing ``CompetencyCriteria`` rows and learner status rows stay resolvable.
8. ``archived``: Boolean, defaults to false. Set instead of deleting a group once a student status exists for it. Archived groups are hidden from authoring and new associations but remain queryable, so existing ``CompetencyCriteria`` rows and learner status rows stay resolvable. For MVP, archiving a group or a criterion is permanent, with the root as the one reversible exception: a new criterion for an archived root's competency reactivates that root instead of creating a second one, because archiving it for good would remove the ability to ever associate criteria with that competency again. Archiving a course-level group blocks that course for that competency; archiving a leaf closes only that leaf to new criteria. More unarchival capabilities may be added after MVP.
9. ``scope_code``: Derived from ``oel_tagging_tag_id``, set on the root group and null on every other group, whether or not the root is archived. It exists so the database can hold at most one root per competency, the same way Decision 3's derived ``scope_code`` holds at most one live profile per scope.

Example: A root group uses "OR" with two child groups.

Expand All @@ -109,8 +110,8 @@ Decision

- Empty groups: Persisted criteria definitions should not contain empty groups. Authoring flows may temporarily create empty groups while editing, but backend validation must reject them.
- Mixed tree depths: Backend supports deeply nested groups. Current frontend authoring constraint is a maximum depth of 3 layers total, using zero-indexed depth (``0=root``, ``1=course-scope group``, ``2=leaf criteria/group``).
- Retrieval scope: A read always includes the root scope (all nodes where ``course_id is null``), plus any selected subset of course-scoped groups, each returned as a complete subtree, never truncated.
- Practical size and growth: Total rows in ``CompetencyCriteriaGroup`` are expected to grow over time as course runs are added; this ADR sets no global DB row cap. No max total node-count cap is required per root group. For ``course_id is null`` branches, expected size is small (realistically <=500 nodes).
- Retrieval scope: A read always includes the root group, plus any selected subset of course-scoped groups, each returned as a complete subtree, never truncated.
- Practical size and growth: Total rows in ``CompetencyCriteriaGroup`` are expected to grow over time as course runs are added; this ADR sets no global DB row cap. No max total node-count cap is required per root group. Each course-scoped subtree is expected to stay small (realistically <=500 nodes).


3. ``CompetencyRuleProfile`` concept (database table)
Expand All @@ -131,7 +132,7 @@ Decision
2. ``organization_id``: The ``organization_id`` of the organization that this competency rule profile is scoped to. Null if it is not scoped to a specific organization.
3. ``course_id``: The ``course_id`` of the course that this competency rule profile is scoped to. Null if it is not scoped to a specific course.
4. ``competency_taxonomy_id``: The ``CompetencyTaxonomy.taxonomy_ptr_id`` of the competency taxonomy that this competency rule profile is scoped to. Null if it is not scoped to a specific taxonomy.
5. ``scope_code``: A plain column in the format ``"org:X,course:Y,taxonomy:Z"``, with each segment left blank when the corresponding scope column is null: for example ``"org:5,course:,taxonomy:"``, or ``"org:,course:,taxonomy:"`` for the system default row. It is non-null when it is live, and null while archived. This frees an archived profile's scope for a replacement.
5. ``scope_code``: A plain column in the format ``"org:X,course:Y,taxonomy:Z"``, with each segment left blank when the corresponding scope column is null: for example ``"org:5,course:,taxonomy:"``, or ``"org:,course:,taxonomy:"`` for the system default row. It is non-null when it is live, and null while archived. This frees an archived profile's scope for a replacement. ``CompetencyCriteriaGroup.scope_code`` (Decision 2) is kept while archived instead: each learner has one ``StudentCompetencyStatus`` row per competency, derived from the competency's root, so an archived root is reactivated rather than replaced, and every rollup and read can rely on exactly one root existing.
6. ``rule_type``: “View”, “Grade”, “MasteryLevel” (Only “Grade” will be supported for now)
7. ``rule_payload``: JSON payload keyed by ``rule_type`` to avoid freeform strings. It is structured JSON (not arbitrary freeform data): each ``rule_type`` defines the allowed payload shape and required keys, and validation enforces this contract. JSON is used instead of fixed columns like ``op``, ``value``, and ``scale`` so that future rule types (for example, ``MasteryLevel`` thresholds or plugin-defined evaluators such as CEL-based rules) can add their own fields without repeated schema migrations or many nullable columns. Examples:

Expand Down Expand Up @@ -246,8 +247,10 @@ Decision
6. ``StudentCompetencyCriteriaStatus(user_id, competency_criteria_id)`` (unique -- each learner status table holds exactly one row per learner and target entity, updated in place; see ADR 0003 Decision 5)
7. ``StudentCompetencyCriteriaGroupStatus(user_id, competency_criteria_group_id)`` (unique)
8. ``StudentCompetencyStatus(user_id, oel_tagging_tag_id)`` (unique)
9. ``CompetencyRuleProfile(scope_code)`` (unique -- at most one profile per distinct scope value; a plain unique constraint on the three raw nullable scope columns would not enforce this, since SQL never treats two ``NULL`` values as equal and this project's MySQL backend does not support the conditional/partial unique indexes that would otherwise route around that; see the ``scope_code`` column in Decision 3)
10. ``CompetencyMasteryStatuses(status)`` (unique)
9. ``CompetencyCriteriaGroup(scope_code)`` (unique -- at most one root group per competency, archived or not; see the ``scope_code`` column in Decision 2)
10. ``CompetencyCriteriaGroup(oel_tagging_tag_id, course_id, parent_id)`` (unique -- at most one course-level group per competency and course)
11. ``CompetencyRuleProfile(scope_code)`` (unique -- at most one profile per distinct scope value; a plain unique constraint on the three raw nullable scope columns would not enforce this, since SQL never treats two ``NULL`` values as equal and this project's MySQL backend does not support the conditional/partial unique indexes that would otherwise route around that; see the ``scope_code`` column in Decision 3)
12. ``CompetencyMasteryStatuses(status)`` (unique)

6. Learner progress status concepts (``StudentCompetency*Status`` database tables)

Expand Down Expand Up @@ -309,6 +312,7 @@ Decision
- Once a related row exists in ``StudentCompetencyCriteriaStatus``, deletion of the associated competency definition row still succeeds, but as an archive (soft delete) instead of a hard delete: the row is hidden from authoring and new associations but remains queryable, so existing learner status rows stay resolvable. This archive-vs-hard-delete rule applies to ``oel_tagging_tag``, ``oel_tagging_taxonomy``, ``CompetencyTaxonomy``, ``oel_tagging_objecttag``, ``CompetencyCriteriaGroup``, and ``CompetencyCriteria``; see :ref:`openedx-learning-adr-0003` Decision 3 for ``oel_tagging_objecttag``'s own archive rule and traceability exception.
- ``StudentCompetencyCriteriaStatus`` is what determines whether a record is protected. ``StudentCompetencyCriteriaGroupStatus`` and ``StudentCompetencyStatus`` are roll-up tables derived from it (Decision 6) and are not independently checked for this purpose: :ref:`openedx-learning-adr-0004` writes the leaf table synchronously with the grade but rolls the two roll-up tables up later via an asynchronous task, which can lag behind the leaf or, per that ADR's Decision 5, need manual recovery. Checking only the roll-up tables could therefore miss real learner progress that has not rolled up yet.
- Direct deletion of a ``CompetencyRuleProfile`` is never a hard delete; retirement is always archive-only, via a normal update to its ``archived`` column (Decision 3). However, if a taxonomy or course that is associated with a taxonomy- or course-scoped profile is deleted, then this profile will be deleted along with it.
- For MVP, archiving a ``CompetencyCriteriaGroup`` or a ``CompetencyCriteria`` row is permanent, with one exception: an archived root ``CompetencyCriteriaGroup`` is reactivated when a new criterion is added for its competency (Decision 2). ``oel_tagging_objecttag`` follows :ref:`openedx-learning-adr-0003` Decision 3 instead.

.. image:: images/CompetencyCriteriaModel.png
:alt: Competency Criteria Model
Expand Down Expand Up @@ -428,6 +432,16 @@ Rejected Alternatives
1. Silently does not work on this project's tested and production database backend. Django compiles a conditional ``UniqueConstraint`` to a partial index, which MySQL does not support; Django raises only a non-fatal system-check warning (``models.W036``) and skips creating the constraint, leaving the uniqueness rule completely unenforced at the database level.
2. The gap would surface only as a data-integrity incident under concurrent writes, not as a test or migration failure, since SQLite (used for quick local test runs) does support partial indexes and would mask the problem in that environment.

7. Let a new root replace an archived root for the same competency, by nulling the root's ``scope_code`` while archived as ``CompetencyRuleProfile`` does (Decision 3), instead of reactivating the archived root.

1. Pros

1. Both ``scope_code`` columns follow the same rule.
2. An archived tree is never modified again.
2. Cons

1. Each learner has a single ``StudentCompetencyStatus`` row per competency, derived from the root. With a live root beside archived ones, every rollup and every read across learners' statuses would first have to decide which root counts.

Changelog
---------

Expand Down Expand Up @@ -458,3 +472,12 @@ Changelog
* ``scope_code`` on ``CompetencyRuleProfile`` is now computed by
application code instead of being database-generated, and is set to null while a
profile is archived, freeing its scope for a replacement.

2026-10-22:

* For issue #872: added ``scope_code`` to ``CompetencyCriteriaGroup`` so the database holds at
most one root per competency, made ``oel_tagging_tag_id`` and ``parent_id`` immutable after
creation, scoped permanent archiving to groups and criteria for MVP with the archived root as
the one reversible exception, and added Rejected Alternative 7. Corrected ``course_id`` and the root scope: only
the course-level group carries ``course_id``, which supersedes the 2026-09-04 entry's statement
that every group in a course-scoped subtree does. That entry is left as originally written.
15 changes: 10 additions & 5 deletions src/openedx_learning/applets/cbe/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,9 +180,9 @@ def get_competency_criteria_tree(tag: Tag) -> CompetencyCriteriaTree:
.order_by(F("parent_id").asc(nulls_first=True), "ordering", "id")
)

# Walk down from the roots instead of relying on row order: ordering by parent_id does not
# guarantee a parent comes before its child once groups can be moved, since a parent may
# then have a larger id than its child.
# Walk down from the roots instead of relying on row order. A group's parent is fixed once
# saved, but a QuerySet.update() can still give a group a parent with a larger id, and
# ordering by parent_id would then visit that child before its parent.
children_by_parent_id: dict[int | None, list[int]] = {}
for group in groups:
children_by_parent_id.setdefault(group.parent_id, []).append(group.id)
Expand Down Expand Up @@ -217,7 +217,9 @@ def _create_leaf_group(
tag/course pair can hold more than one leaf, one per criterion. The root and course-level
group names are truncated to fit the name column; they are never shown in the UI.

Must run inside a transaction holding the tag row lock.
The root and course-level lookups need no lock of their own: the unique constraints hold one
root per tag and one course-level group per tag and course, and ``get_or_create()`` recovers
from a concurrent insert. The caller's tag lock is for ``_reject_duplicate_criterion()``.
"""
name_max_length = CompetencyCriteriaGroup._meta.get_field("name").max_length
# Both get_or_create() lookups match archived rows deliberately, so an archived container is
Expand Down Expand Up @@ -494,7 +496,10 @@ def associate_competency_criterion(

with transaction.atomic():
# Lock order is always the tag, then the course run, so two creates can't deadlock.
# Serializes the duplicate check and root creation; MySQL has no partial unique index for one root per tag.
# The database enforces one root per tag; the tag lock only serializes _reject_duplicate_criterion(). That
# check is check-then-act on both paths: without group_id a request always creates a fresh leaf, and with
# group_id CompetencyCriterion has no unique constraint on (object_tag, group), so no index catches a
# concurrent copy.
Tag.objects.select_for_update().get(pk=tag.pk)
# Serializes the per-course dominance check on CBE's own row, not the catalog's CourseRun.
# Created first, since a locking read of a row that doesn't exist yet locks nothing; get_or_create()
Expand Down
60 changes: 58 additions & 2 deletions src/openedx_learning/applets/cbe/models/criteria.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ class CompetencyCriteriaGroup(models.Model):
of child groups and leaf criteria, and only CompetencyCriteriaGroup carries an ``ordering``
field, so that mix has no total order; #641 accepts this deliberately. See ADR-0002 Decision 2.

``scope_code`` is derived in ``save()``, so writes that skip ``save()`` (``bulk_create()``,
``QuerySet.update()``, ``loaddata``, raw SQL) can leave a root without a code or with a stale
one, and the one-root-per-competency rule then goes unenforced for that root.

.. no_pii:
"""

Expand Down Expand Up @@ -104,8 +108,20 @@ class CompetencyCriteriaGroup(models.Model):
"criteria and existing learner status rows stay resolvable."
),
)
# Recomputed in save(); set for roots only, archived or not, so an archived root keeps blocking a second one.
# Null below the root: a leaf has no natural key to put here, and the check constraint below only says a
# coded row is a root, not that every root is coded, so non-null would not make the rule any stronger.
scope_code = models.CharField(
max_length=255,
null=True,
Comment on lines +111 to +116

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit (optional): Please consider adding line here on why this stays null below the root (a leaf has no natural key to put here, and the one-way check below means non-null wouldn't buy a stronger constraint anyway) would save the next person from asking my question again.

editable=False,
help_text=_(
"Derived from tag and parent; \"tag:X\" for a root group, null for every other group."
),
)

history = HistoricalRecords()
# scope_code is derived bookkeeping; tag and parent, which it derives from, are already tracked.
history = HistoricalRecords(excluded_fields=["scope_code"])

class Meta:
indexes = [
Expand All @@ -121,16 +137,56 @@ class Meta:
constraints = [
# Backstops _create_leaf_group()'s get_or_create() for course-level groups. It has no
# `condition` because MySQL has no partial indexes: a unique index allows repeated
# NULLs, and `course` is NULL for roots and leaves.
# NULLs, and `course` is NULL for roots and leaves. Roots are covered by scope_code instead.
models.UniqueConstraint(
fields=["tag", "course", "parent"],
name="oel_cbe_criteria_group_one_course_group_per_tag_course",
violation_error_message=_(
"A competency tag may have at most one course-level CompetencyCriteriaGroup per course."
),
),
# Over the derived scope_code column because MySQL has no partial unique index for `parent IS NULL`.
models.UniqueConstraint(
fields=["scope_code"],
name="oel_cbe_criteria_group_scope_code_uniq",
violation_error_message=_("A competency tag may have at most one root CompetencyCriteriaGroup."),
),
# One-directional: on MySQL, which cannot defer constraint checks, Django's delete collector
# sets each descendant's nullable `parent` to NULL before deleting it, so "every root has a
# scope_code" would reject those rows mid-delete.
models.CheckConstraint(
condition=Q(scope_code__isnull=True) | Q(parent__isnull=True),
name="oel_cbe_criteria_group_scope_code_root_only_check",
violation_error_message=_("Only a root CompetencyCriteriaGroup may have a scope_code."),
),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: Is this more trouble than it's worth? I think I get the reasoning for this, in that the scope_code is only strictly necessary when one of these values is null, and that we're essentially making a sort of composite constraint where the UniqueConstraint on scope_code is only there to cover what the other UniqueConstraint on (tag, course, parent) can't. Still, it feels fragile, and I'd feel more comfortable if scope_code always existed (i.e. was not nullable).

I realize this uses more space, and that it may complicate the migration a bit (create nullable field, backfill, make field no longer nullable). But it would reduce the code complexity and guard against things like bulk insertion later bypassing our constraints and causing data corruption.

I can be argued out of this one. Please let me know if there's something I'm not seeing here.

]

def _check_tree_position_immutable(self) -> None:
"""Raise ValidationError if tag, parent or course no longer matches what is persisted for this row."""
if self.pk is None:
return
# Queried rather than compared against a value cached at load time, so a deferred load or
# a refresh_from_db() cannot bypass the check.
persisted = (
CompetencyCriteriaGroup.objects.filter(pk=self.pk).values_list("tag_id", "parent_id", "course_id").first()
)
if persisted is not None and (self.tag_id, self.parent_id, self.course_id) != persisted:
raise ValidationError(
_("A CompetencyCriteriaGroup's tag, parent and course cannot be changed after creation.")
)
Comment on lines +164 to +176

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two of #681's tests reparent a group through save() and fail against this check once the branch is on main: test_get_competency_criteria_tree_keeps_a_group_moved_under_a_later_created_parent in test_api.py and test_tree_response_never_references_a_group_it_left_out in test_views.py. Please rework them as part of the rebase. The first one guards the tree walk against a parent_id that points at a larger id, which is now only reachable behind save(), so building that shape with .filter(pk=group_b.pk).update(parent=group_c) keeps the test honest about what it defends against (the check constraint only forbids giving a root a parent). The second one just needs its kept leaf under a second course run's course-level group instead of being moved under the root. Claude made both changes on a trial merge and the suite passed, so neither needs more than that.

Likewise, the comment above the walk in get_competency_criteria_tree() ("ordering by parent_id does not guarantee a parent comes before its child once groups can be moved") now contradicts ADR-0002 and this method. The walk is still worth keeping, since nothing stops a QuerySet.update() from producing that shape, but please reword it so the next reader doesn't take it as a plan to make groups movable.

FWIW, I'm fine with parent being immutable: #760 already rejects a parent change at the API, relaxing this later is a one-line change, and tightening it after rows have moved around would not be.


def _compute_scope_code(self) -> str | None:
"""Return this group's scope_code: set for a root group, None for every other group."""
if self.parent_id is not None:
return None
return f"tag:{self.tag_id}"

def save(self, *args, **kwargs):
"""Reject a moved or re-tagged group, then set scope_code; tag and parent are fixed, so the code is too."""
self._check_tree_position_immutable()
self.scope_code = self._compute_scope_code()
super().save(*args, **kwargs)


class CompetencyRuleProfile(models.Model):
"""
Expand Down
Loading
Loading