Skip to content
Open
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
2 changes: 2 additions & 0 deletions .annotation_safe_list.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ openedx_content.Unit:
".. no_pii:": "This model has no PII"
openedx_content.UnitVersion:
".. no_pii:": "This model has no PII"
openedx_learning.HistoricalCompetencyCriteriaGroup:
".. no_pii:": "This model has no PII"
social_django.Association:
".. no_pii:": "This model has no PII"
social_django.Code:
Expand Down
3 changes: 3 additions & 0 deletions src/openedx_learning/applets/cbe/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
"""

from .competency_taxonomy import CompetencyTaxonomy
from .criteria import CompetencyCriteriaGroup, LogicOperator

__all__ = [
"CompetencyCriteriaGroup",
"CompetencyTaxonomy",
"LogicOperator",
]
100 changes: 100 additions & 0 deletions src/openedx_learning/applets/cbe/models/criteria.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""
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.
"""
from __future__ import annotations

from django.db import models
from django.utils.translation import gettext_lazy as _
from simple_history.models import HistoricalRecords

from openedx_catalog.models import CourseRun
from openedx_django_lib.fields import case_insensitive_char_field, immutable_uuid_field
from openedx_tagging.models import Tag

__all__ = [
"CompetencyCriteriaGroup",
"LogicOperator",
]


class LogicOperator(models.TextChoices):
"""How a CompetencyCriteriaGroup combines its child nodes."""

AND = "AND", _("And")
OR = "OR", _("Or")


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?


A single CompetencyAchievementCriteria is one root CompetencyCriteriaGroup plus all of its
descendant groups and leaf :class:`CompetencyCriterion` rows. ``logic_operator`` says how
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?

field, so that mix has no total order; #641 accepts this deliberately. See ADR-0002 Decision 2.

.. no_pii:
"""

uuid = immutable_uuid_field()
parent = models.ForeignKey(
"self",
null=True,
blank=True,
on_delete=models.CASCADE,
related_name="child_groups",
help_text=_("The parent CompetencyCriteriaGroup. Null means this group is a tree root."),
)
tag = models.ForeignKey(
Tag,
db_column="oel_tagging_tag_id",
on_delete=models.CASCADE,
related_name="competency_criteria_groups",
help_text=_("The competency (tag) that this criteria tree evaluates mastery of."),
)
course = models.ForeignKey(
CourseRun,
null=True,
blank=True,
on_delete=models.CASCADE,
related_name="competency_criteria_groups",
help_text=_("The course run that scopes this criteria tree for evaluation windowing, if any."),
)
name = case_insensitive_char_field(
max_length=255, blank=True, default="", help_text=_("A human-readable label for this group, if any.")
)
ordering = models.PositiveIntegerField(
default=0,
help_text=_(
"Deterministic sibling evaluation sequence. Used to short-circuit evaluation and to order "
"child scans during event-driven recomputation."
),
)
logic_operator = models.CharField(
max_length=3,
choices=LogicOperator,
null=True,
blank=True,
help_text=_(
"How this group's children combine. Null only for a group with a single child, where combining "
"logic is moot; the application layer treats null the same as OR."
),
)

history = HistoricalRecords()

class Meta:
indexes = [
# ADR-0002 Decision 5, index 1: lookups by competency tag and course scope.
models.Index(fields=["tag", "course"]),
# ADR-0002 Decision 5 also lists an index on `parent` (index 2), but Django already
# indexes every ForeignKey column by default, so a second explicit one here would only
# cost write throughput without adding any read benefit.
]
65 changes: 65 additions & 0 deletions src/openedx_learning/migrations/0003_competencycriteriagroup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Generated by Django 5.2.16 on 2026-09-10 18:32

import uuid

import django.db.models.deletion
import simple_history.models
from django.conf import settings
from django.db import migrations, models

import openedx_django_lib.fields


class Migration(migrations.Migration):

dependencies = [
('oel_tagging', '0021_remove_system_defined_add_read_only'),
('openedx_catalog', '0001_initial'),
('openedx_learning', '0002_competencytaxonomy_taxonomy_overrides_org'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name='CompetencyCriteriaGroup',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='UUID')),
('name', openedx_django_lib.fields.MultiCollationCharField(blank=True, db_collations={'mysql': 'utf8mb4_unicode_ci', 'sqlite': 'NOCASE'}, default='', help_text='A human-readable label for this group, if any.', max_length=255)),
('ordering', models.PositiveIntegerField(default=0, help_text='Deterministic sibling evaluation sequence. Used to short-circuit evaluation and to order child scans during event-driven recomputation.')),
('logic_operator', models.CharField(blank=True, choices=[('AND', 'And'), ('OR', 'Or')], help_text="How this group's children combine. Null only for a group with a single child, where combining logic is moot; the application layer treats null the same as OR.", max_length=3, null=True)),
('course', models.ForeignKey(blank=True, help_text='The course run that scopes this criteria tree for evaluation windowing, if any.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='competency_criteria_groups', to='openedx_catalog.courserun')),
('parent', models.ForeignKey(blank=True, help_text='The parent CompetencyCriteriaGroup. Null means this group is a tree root.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='child_groups', to='openedx_learning.competencycriteriagroup')),
('tag', models.ForeignKey(db_column='oel_tagging_tag_id', help_text='The competency (tag) that this criteria tree evaluates mastery of.', on_delete=django.db.models.deletion.CASCADE, related_name='competency_criteria_groups', to='oel_tagging.tag')),
],
),
migrations.CreateModel(
name='HistoricalCompetencyCriteriaGroup',
fields=[
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, verbose_name='UUID')),
('name', openedx_django_lib.fields.MultiCollationCharField(blank=True, db_collations={'mysql': 'utf8mb4_unicode_ci', 'sqlite': 'NOCASE'}, default='', help_text='A human-readable label for this group, if any.', max_length=255)),
('ordering', models.PositiveIntegerField(default=0, help_text='Deterministic sibling evaluation sequence. Used to short-circuit evaluation and to order child scans during event-driven recomputation.')),
('logic_operator', models.CharField(blank=True, choices=[('AND', 'And'), ('OR', 'Or')], help_text="How this group's children combine. Null only for a group with a single child, where combining logic is moot; the application layer treats null the same as OR.", max_length=3, null=True)),
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField(db_index=True)),
('history_change_reason', models.CharField(max_length=100, null=True)),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
('course', models.ForeignKey(blank=True, db_constraint=False, help_text='The course run that scopes this criteria tree for evaluation windowing, if any.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='openedx_catalog.courserun')),
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('parent', models.ForeignKey(blank=True, db_constraint=False, help_text='The parent CompetencyCriteriaGroup. Null means this group is a tree root.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='openedx_learning.competencycriteriagroup')),
('tag', models.ForeignKey(blank=True, db_column='oel_tagging_tag_id', db_constraint=False, help_text='The competency (tag) that this criteria tree evaluates mastery of.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='oel_tagging.tag')),
],
options={
'verbose_name': 'historical competency criteria group',
'verbose_name_plural': 'historical competency criteria groups',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': ('history_date', 'history_id'),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.AddIndex(
model_name='competencycriteriagroup',
index=models.Index(fields=['tag', 'course'], name='openedx_lea_oel_tag_737416_idx'),
),
]
40 changes: 40 additions & 0 deletions tests/openedx_learning/applets/cbe/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""Shared fixtures for the CBE criteria test modules."""
import pytest
from organizations.api import ensure_organization
from organizations.models import Organization

from openedx_catalog.models import CatalogCourse, CourseRun
from openedx_learning.models import CompetencyCriteriaGroup, CompetencyTaxonomy
from openedx_tagging.models import Tag


@pytest.fixture(name="organization")
def _organization() -> Organization:
"""An Organization for use as a scope in these tests."""
ensure_organization("Org1")
return Organization.objects.get(short_name="Org1")


@pytest.fixture(name="course_run")
def _course_run(organization: Organization) -> CourseRun:
"""A CourseRun for use as a scope in these tests."""
catalog_course = CatalogCourse.objects.create(org=organization, course_code="Python100")
return CourseRun.objects.create(catalog_course=catalog_course, run_code="Fall2026")


@pytest.fixture(name="competency_taxonomy")
def _competency_taxonomy() -> CompetencyTaxonomy:
"""A CompetencyTaxonomy for use as a scope, and as the home taxonomy for `tag`."""
return CompetencyTaxonomy.objects.create(name="Nursing", export_id="nursing-v1")


@pytest.fixture(name="tag")
def _tag(competency_taxonomy: CompetencyTaxonomy) -> Tag:
"""A Tag, from `competency_taxonomy`, for use as the competency a criteria tree evaluates."""
return Tag.objects.create(taxonomy=competency_taxonomy, value="Writing Poetry")


@pytest.fixture(name="group")
def _group(tag: Tag) -> CompetencyCriteriaGroup:
"""A root CompetencyCriteriaGroup for `tag`, for use as a criterion's parent group."""
return CompetencyCriteriaGroup.objects.create(tag=tag)
Loading