Skip to content

feat: add CompetencyCriteriaGroup, the criteria tree's AND/OR node - #4

Open
jesperhodge wants to merge 6 commits into
jesperhodge/cbe-641-03-taxonomy-overrides-orgfrom
jesperhodge/cbe-641-04-criteria-group
Open

jesperhodge wants to merge 6 commits into
jesperhodge/cbe-641-03-taxonomy-overrides-orgfrom
jesperhodge/cbe-641-04-criteria-group

Conversation

@jesperhodge

@jesperhodge jesperhodge commented Sep 9, 2026

Copy link
Copy Markdown
Owner

Part 4 of 7 for issue openedx#641. Based on part 3.

One deviation from openedx#641's AC 26. AC 26 lists course as PROTECT. AC 26 needs updating so the
ticket and the code agree.

What this does

Adds CompetencyCriteriaGroup, the internal AND/OR node of a criteria tree. One CompetencyAchievementCriteria is one root group plus all of its descendant groups and, once part 7 lands, the leaf criteria under any of them. See ADR-0002 Decision 2.

Two constraints that are deliberately absent

  • Nothing ties logic_operator to child count, and an empty group is not rejected. A group's AND/OR setting is never actually checked against what's inside it, because a group is always empty the moment it's created, and nothing re-checks it later when children get added one by one.
  • No UniqueConstraint on (parent, ordering). There's no rule stopping two children from having the same order position, because a group's children are two different kinds of things: sub-groups, and leaves; and only one kind even has an order value. So the rule would only half-work while looking complete.

Both belong to the authoring API, which no ticket owns yet.

Reviewing this PR

The test module is ordered so that reading it top to bottom gives the model's contract: schema, then tree shape, then the two absent constraints, then indexes and history. Each test name states the expected behavior.

One test would pass for the wrong reason if written naively. test_the_database_carries_adr_0002_decision_5_indexes_1_and_2 compares the ordered column list, not a set. An index on (course_id, tag_id) would satisfy a set comparison just as well, but only the tag-first ordering also serves tag-only lookups.

Verifying

pytest tests/openedx_learning --no-cov -q                            # 18 passed
python manage.py makemigrations openedx_learning --check --dry-run   # no changes detected
lint-imports                                                         # the openedx_catalog import from part 2 is now real

By hand, confirm the tree links work and that a parent delete cascades to its children:

from openedx_learning.models import CompetencyCriteriaGroup, CompetencyTaxonomy
from openedx_tagging.models import Tag

taxonomy = CompetencyTaxonomy.objects.create(name="T", export_id="t")
tag = Tag.objects.create(taxonomy=taxonomy, value="Writing")
root = CompetencyCriteriaGroup.objects.create(tag=tag)
child = CompetencyCriteriaGroup.objects.create(tag=tag, parent=root)
child.parent_id == root.pk        # True
root.delete()                     # CASCADE: child is deleted with it, not re-rooted or orphaned
CompetencyCriteriaGroup.objects.filter(pk=child.pk).exists()   # False

Refs openedx#641

@jesperhodge
jesperhodge force-pushed the jesperhodge/cbe-641-04-criteria-group branch from 2eaab26 to 35f98ea Compare September 9, 2026 21:26
@jesperhodge
jesperhodge force-pushed the jesperhodge/cbe-641-04-criteria-group branch from 35f98ea to 09593cd Compare September 9, 2026 21:48
@jesperhodge
jesperhodge force-pushed the jesperhodge/cbe-641-04-criteria-group branch from 09593cd to 9fb5dcd Compare September 10, 2026 03:03
@jesperhodge
jesperhodge force-pushed the jesperhodge/cbe-641-04-criteria-group branch from 9fb5dcd to bc775d0 Compare September 10, 2026 19:03
@jesperhodge
jesperhodge added this pull request to stack #11 September 11, 2026 13:17
@jesperhodge
jesperhodge force-pushed the jesperhodge/cbe-641-04-criteria-group branch 2 times, most recently from c1b7be3 to 80422d6 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
Comment thread src/openedx_learning/applets/cbe/models/criteria.py Outdated

class CompetencyCriteriaGroup(models.Model):
"""
An internal AND/OR node in a CompetencyAchievementCriteria expression tree.

@jesperhodge jesperhodge Sep 11, 2026

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

nit: Since the term CompetencyAchievementCriteria has been confusing, we should workshop this. Will it be its own non-database class? If so, is there a better name? If not, can we explain this more clearly?

this group's own children combine. ``ordering`` gives this group's own position among its
siblings under their shared parent, which read-time evaluation and event-driven recomputation
rely on for deterministic, short-circuiting evaluation order. A group's children can be a mix
of child groups and leaf criteria, and only CompetencyCriteriaGroup carries an ``ordering``

@jesperhodge jesperhodge Sep 11, 2026

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

nit / discussion for later: Do leaf criteria maybe need an ordering field as well?

@mgwozdz-unicon mgwozdz-unicon left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Claude and I both reviewed. All change requests were identified by me; comment drafted by Claude:

The data model and migration are correct. Everything below is naming, docstrings, and test quality, not logic. Requesting changes for the items marked as such; the rest are direct answers to open questions in this thread.

src/openedx_learning/applets/cbe/models/criteria.py

1. Module docstring (lines 2-6): drop CompetencyAchievementCriteria, summarize the ADR decisions instead of only pointing at them, and lead with what this tree is actually for.

Current:

"""
The CompetencyAchievementCriteria tree: CompetencyCriteriaGroup, the internal AND/OR node.

See :ref:`openedx-learning-adr-0002` Decision 2 for the design and Decision 7 for why every
foreign key here cascades, and :ref:`openedx-learning-adr-0003` Decisions 1 and 2 for why this
model carries ``django-simple-history`` tracking and CompetencyTaxonomy does not.
"""

Requested:

"""
The Competency Criteria tree: the AND/OR hierarchy of criteria that defines the rule for
demonstrating mastery of one competency. CompetencyCriteriaGroup is this tree's internal
AND/OR node: each group combines its child nodes, which may themselves be groups or, at the
bottom of the tree, leaf :class:`CompetencyCriterion` rows. Each leaf names the learning
object and the threshold used to measure it.

Every foreign key here cascades, so that deleting a tag, a course run, or a parent group
removes the whole subtree beneath it (:ref:`openedx-learning-adr-0002` Decision 7). This
model carries ``django-simple-history`` tracking so that changes to a criteria tree are
auditable (:ref:`openedx-learning-adr-0003` Decisions 1 and 2).
"""

Three changes bundled here, since they all land on the same docstring:

  • Lead with what the tree is for before describing its shape: one AND/OR hierarchy of criteria per competency, bottoming out at the leaf criteria that actually measure something.
  • Answering the open thread on this docstring: CompetencyAchievementCriteria is not a class and will not become one. The model breakdown across openedx#613/openedx#641 is CompetencyCriteriaGroup (internal nodes) plus CompetencyCriterion (leaf nodes); there is no third class. CompetencyAchievementCriteria appears four times in this PR, all as prose, never as an identifier: this docstring's first line (being replaced here), the class docstring two paragraphs below (criteria.py:33 and criteria.py:35, item 2 below), and the test module docstring (test_criteria_group.py:2, item 3 below). Please replace all four with plain prose "Competency Criteria tree" (no code formatting). Formatting it in double backticks anywhere makes it look like a code symbol, which is what's generating the "is this a class?" questions in this thread. ADR-0002 has the same code-formatted usage in several places; that's a separate, non-blocking cleanup for whoever next touches that ADR, not something to fix in this PR.
  • Drop "and CompetencyTaxonomy does not" from the history sentence. That's a fact about a different model and belongs in CompetencyTaxonomy's own docstring or in ADR-0003, not repeated here.

2. Class docstring (lines 33, 35): same rename.

Current:

class CompetencyCriteriaGroup(models.Model):
    """
    An internal AND/OR node in a CompetencyAchievementCriteria expression tree.

    A single CompetencyAchievementCriteria is one root CompetencyCriteriaGroup plus all of its
    descendant groups and leaf :class:`CompetencyCriterion` rows. ...

Requested:

class CompetencyCriteriaGroup(models.Model):
    """
    An internal AND/OR node in a Competency Criteria tree.

    A single Competency Criteria tree is one root CompetencyCriteriaGroup plus all of its
    descendant groups and leaf :class:`CompetencyCriterion` rows. ...

tests/openedx_learning/applets/cbe/test_criteria_group.py

3. Line 2: same rename. "...the internal AND/OR node of a CompetencyAchievementCriteria tree" becomes "...the internal AND/OR node of a Competency Criteria tree."

4. Line 65, test_group_logic_operator_accepts_and_or_and_null_regardless_of_child_count: the assertion for the 2-children case doesn't test what the name claims. The final line, assert CompetencyCriteriaGroup.objects.filter(parent=parent).count() == 2, only proves two children can be created under a parent. It never checks that the parent's own logic_operator value is still what it was set to once children exist, which is the actual claim in the test's name and docstring. Please add a check on the parent itself before the existing assertion:

    parent = CompetencyCriteriaGroup.objects.create(tag=tag, logic_operator=logic_operator)
    CompetencyCriteriaGroup.objects.create(tag=tag, parent=parent)
    CompetencyCriteriaGroup.objects.create(tag=tag, parent=parent)
    parent.refresh_from_db()
    assert parent.logic_operator == logic_operator
    assert CompetencyCriteriaGroup.objects.filter(parent=parent).count() == 2

5. Line 121: rename test_the_database_carries_adr_0002_decision_5_indexes_1_and_2 to describe the behavior, not the ADR decision it comes from. Requested name: test_composite_tag_course_index_and_automatic_parent_index_exist.

6. Lines 122-129: rewrite the docstring to describe the indexes in its own words instead of citing "ADR-0002 Decision 5." State these as facts about this table directly, rather than by reference to the ADR: the index-level detail lives in ADR-0002 at a level of specificity an ADR shouldn't really carry, so a docstring that leans on "ADR-0002 Decision 5" to explain itself is pointing at something that may not stay worded that way.

Current:

    """
    The real table carries ADR-0002 Decision 5's index 1, the composite (tag, course), and index
    2 on parent. Index 2 comes from Django's automatic per-ForeignKey index rather than an
    explicit models.Index, so this introspects the database rather than the model.

    Compares the ordered column list, not a set: column order is the whole point of a composite
    index. An index on (course_id, oel_tagging_tag_id) would satisfy a set comparison just as
    well, but only the tag-first ordering also serves tag-only lookups.
    """

Requested:

    """
    The table carries a composite index on (tag, course), declared explicitly in Meta.indexes,
    and a second index on parent, which Django creates automatically for every ForeignKey and
    which this model does not declare explicitly. This test introspects the real database
    rather than the model definition, since the parent index exists only as a side effect of
    Django's default behavior.

    Compares the ordered column list, not a set: column order is the whole point of a composite
    index. An index on (course_id, oel_tagging_tag_id) would satisfy a set comparison just as
    well, but only the tag-first ordering also serves tag-only lookups.
    """

7. Lines 162-179, test_history_not_recorded_for_tag_taxonomy_or_competencytaxonomy: remove this test from this file. It asserts that Tag and Taxonomy (owned by openedx_tagging, not this app) have no history tracking, alongside CompetencyTaxonomy. None of that is about CompetencyCriteriaGroup, which is what this file covers. If a change in openedx_tagging ever adds history tracking to Tag or Taxonomy for reasons unrelated to CBE, this unrelated test file breaks for a reason nobody touching this file would expect. If this coverage is worth keeping, it belongs in a test module owned by whichever app owns that decision (openedx_tagging's own tests, or a dedicated ADR-0003 versioning test module), not here.

tests/openedx_learning/applets/cbe/test_criteria_group_deletion.py

8. Lines 83-95, test_a_cascaded_group_removal_is_recorded_in_history: remove, it duplicates lines 49-64. test_deleting_a_tag_also_deletes_its_competency_criteria_groups (lines 49-64) already creates a group, deletes its tag, and asserts a HistoricalCompetencyCriteriaGroup row with history_type='-' exists for that group, in addition to asserting the group itself is gone. The test at line 83 does the identical setup, delete, and history assertion, and nothing else. If you want to keep the framing that this specifically covers a cascade-triggered removal rather than a direct delete, fold that sentence into the docstring of the line-49 test instead of keeping a second test whose assertions are a strict subset of the first.

jesperhodge and others added 5 commits September 14, 2026 12:40
A single CompetencyAchievementCriteria is one root CompetencyCriteriaGroup plus
all of its descendant groups, per ADR-0002 Decision 2.

Two constraints a reader may expect are deliberately absent: nothing ties
logic_operator to child count, and there is no UniqueConstraint on
(parent, ordering). A child group cannot be saved until its parent's primary
key exists, so a parent's clean() always sees zero children, and leaf criteria
carry no ordering column at all, so sibling order among them would stay
undefined while looking solved. Both belong to the authoring API.

All three foreign keys cascade, per ADR-0002 Decision 7. A criteria tree means
nothing without the competency it evaluates, the course run that scopes it, or
the group above it, so on_delete here expresses containment rather than
protection. Those same cascades are how a delete reaches the PROTECT on the
learner status tables, which is where deletion is actually refused.

Refs openedx#641

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
These five tests only need CompetencyCriteriaGroup, so they belong with the
on_delete values this PR already declares, not in the cross-model part 8
suite they were drafted alongside.
…rride

Issue openedx#641's AC asks for group deletion to cascade at any depth, for a
taxonomy delete to reach this model's criteria groups transitively, and
for no delete() override to land in this ticket. Only depth-1 cascade
and the tag-level cascade were pinned; add the three gaps directly.
Deleting the middle node only re-proved the one-hop cascade the depth-1
test already covers. Deleting the root and checking the grandchild is
what actually exercises the collector recursing through more than one
level from a single delete.
Not valuable enough to keep.
@jesperhodge
jesperhodge force-pushed the jesperhodge/cbe-641-04-criteria-group branch from 694174b to 8efbb1c Compare September 14, 2026 16:40
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.

2 participants