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 @@ -79,6 +79,8 @@ openedx_content.UnitVersion:
".. no_pii:": "This model has no PII"
openedx_learning.HistoricalCompetencyCriteriaGroup:
".. no_pii:": "This model has no PII"
openedx_learning.HistoricalCompetencyRuleProfile:
".. no_pii:": "This model has no PII"
social_django.Association:
".. no_pii:": "This model has no PII"
social_django.Code:
Expand Down
5 changes: 4 additions & 1 deletion src/openedx_learning/applets/cbe/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@
Models for Competency-Based Education (CBE).
"""

from ..rule_payloads import RuleType
from .competency_taxonomy import CompetencyTaxonomy
from .criteria import CompetencyCriteriaGroup, LogicOperator
from .criteria import CompetencyCriteriaGroup, CompetencyRuleProfile, LogicOperator

__all__ = [
"CompetencyCriteriaGroup",
"CompetencyRuleProfile",
"CompetencyTaxonomy",
"LogicOperator",
"RuleType",
]
169 changes: 165 additions & 4 deletions src/openedx_learning/applets/cbe/models/criteria.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,30 @@
"""
The CompetencyAchievementCriteria tree: CompetencyCriteriaGroup, the internal AND/OR node.
The CompetencyAchievementCriteria models: CompetencyCriteriaGroup, the internal AND/OR node,
and CompetencyRuleProfile, the reusable evaluation rule its leaves draw from.

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.
See :ref:`openedx-learning-adr-0002` Decisions 2 and 3 for the design and Decision 7 for each
foreign key's delete behavior, and :ref:`openedx-learning-adr-0003` Decisions 1 and 2 for why
these models carry ``django-simple-history`` tracking and CompetencyTaxonomy does not.
"""
from __future__ import annotations

from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from organizations.models import Organization
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

from ..rule_payloads import RuleType, validate_rule_payload
from .competency_taxonomy import CompetencyTaxonomy

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

Expand Down Expand Up @@ -98,3 +106,156 @@ class Meta:
# indexes every ForeignKey column by default, so a second explicit one here would only
# cost write throughput without adding any read benefit.
]


class CompetencyRuleProfile(models.Model):
"""
A reusable default evaluation rule, optionally scoped to a taxonomy, course, or organization.

Each row is scoped by at most one of ``organization``, ``course``, and ``competency_taxonomy``,
enforced by the check constraint below; the row with all three null is the system default,
seeded once by migration and never created or deleted through the profile API. See ADR-0002
Decision 3 for how a :class:`CompetencyCriterion` is assigned one of these, and Decision 4 for
what happens when more than one scope's profile could apply to the same criterion.

A profile's scope is immutable after creation; only ``rule_type``, ``rule_payload`` and
``archived`` may change.

.. no_pii:
"""

uuid = immutable_uuid_field()
organization = models.ForeignKey(
Organization,
null=True,
blank=True,
on_delete=models.PROTECT,
related_name="competency_rule_profiles",
help_text=_("The organization this profile is scoped to, if any."),
)
course = models.ForeignKey(
CourseRun,
null=True,
blank=True,
on_delete=models.CASCADE,
related_name="competency_rule_profiles",
help_text=_("The course run this profile is scoped to, if any."),
)
competency_taxonomy = models.ForeignKey(
CompetencyTaxonomy,
null=True,
blank=True,
on_delete=models.CASCADE,
related_name="rule_profiles",
help_text=_("The competency taxonomy this profile is scoped to, if any."),
)
# Recomputed in save(), never set directly: null while archived, so any number of archived
# rows may share a scope while exactly one live row holds it, which is what lets an archived
# profile be replaced. See ADR-0002 Decision 3.
scope_code = models.CharField(
max_length=255,
null=True,
editable=False,
help_text=_(
"Derived from organization/course/competency_taxonomy; null while archived, otherwise "
"\"org:X,course:Y,taxonomy:Z\" with each segment blank when that scope column is null."
),
)
rule_type = models.CharField(max_length=32, choices=RuleType)
rule_payload = models.JSONField(
help_text=_(
'Structured payload whose keys are set by rule_type. A "Grade" payload is '
'{"op": "gte" | "lte" | "eq", "value": a fraction from 0.0 to 1.0, "scale": "percent"}.'
)
)
archived = models.BooleanField(
default=False,
help_text=_(
"Hides a profile from authoring and from new associations while keeping it queryable, so "
"criteria already assigned to it stay resolvable."
),
)

# scope_code is excluded from history: it is a derived, non-editable bookkeeping column (see
# above), not an author-facing fact worth its own historical row -- the columns it derives
# from (organization, course, competency_taxonomy, archived) are already tracked, and are what
# an audit trail actually needs.
history = HistoricalRecords(excluded_fields=["scope_code"])

class Meta:
constraints = [
# Unconditional, over the derived scope_code column rather than the raw nullable
# scope columns: MySQL has no partial unique indexes and Django silently skips
# creating one there. See ADR-0002 Rejected Alternative 6.
models.UniqueConstraint(fields=["scope_code"], name="oel_cbe_ruleprofile_scope_code_uniq"),
models.CheckConstraint(
# Expressed as "at least two of the three scope columns are null", i.e. at most one
# is non-null.
condition=(
Q(organization__isnull=True, course__isnull=True)
| Q(organization__isnull=True, competency_taxonomy__isnull=True)
| Q(course__isnull=True, competency_taxonomy__isnull=True)
),
name="oel_cbe_ruleprofile_scope_check",
violation_error_message=_(
"A CompetencyRuleProfile may be scoped to at most one of organization, course, and "
"competency_taxonomy."
),
),
models.CheckConstraint(
# Keeps scope_code's invariant honest against QuerySet.update(), which bypasses
# save(): the database refuses the row rather than letting this get out of sync
# behind save()'s back.
condition=(
Q(archived=True, scope_code__isnull=True) | Q(archived=False, scope_code__isnull=False)
),
name="oel_cbe_ruleprofile_archived_scope_code_check",
violation_error_message=_(
"An archived CompetencyRuleProfile must have a null scope_code; a live one must not."
),
),
]

def _check_scope_immutable(self) -> None:
"""Raise ValidationError if the scope columns no longer match what is persisted for this row."""
if self.pk is None:
# A new, unsaved instance: there's no persisted scope yet to compare against.
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_scope = (
CompetencyRuleProfile.objects.filter(pk=self.pk)
.values_list("organization_id", "course_id", "competency_taxonomy_id")
.first()
)
if persisted_scope is None:
return
current_scope = (self.organization_id, self.course_id, self.competency_taxonomy_id)
if current_scope != persisted_scope:
raise ValidationError(
_(
"A CompetencyRuleProfile's scope (organization, course, competency_taxonomy) cannot be "
"changed after creation."
)
)

def clean(self):
"""Validate scope immutability and the rule_payload shape for rule_type."""
super().clean()
self._check_scope_immutable()
validate_rule_payload(self.rule_type, self.rule_payload)

def _compute_scope_code(self) -> str | None:
"""Return this profile's scope_code, or None while it is archived."""
if self.archived:
return None
# A blank segment, not "None", for an unset scope: ADR-0002 Decision 3 fixes this format.
org, course, taxonomy = self.organization_id, self.course_id, self.competency_taxonomy_id
return f"org:{org or ''},course:{course or ''},taxonomy:{taxonomy or ''}"

def save(self, *args, **kwargs):
"""On save: recompute and validate scope_code."""
self.scope_code = self._compute_scope_code()
# validate_unique() is already enforced by the database.
self.full_clean(validate_unique=False, validate_constraints=False)
super().save(*args, **kwargs)
63 changes: 63 additions & 0 deletions src/openedx_learning/migrations/0004_competencyruleprofile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Generated by Django 5.2.16 on 2026-09-10 18:33

import uuid

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


class Migration(migrations.Migration):

dependencies = [
('openedx_catalog', '0001_initial'),
('openedx_learning', '0003_competencycriteriagroup'),
('organizations', '0005_competencyruleprofile'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name='HistoricalCompetencyRuleProfile',
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')),
('rule_type', models.CharField(choices=[('Grade', 'Grade')], max_length=32)),
('rule_payload', models.JSONField(help_text='Structured payload whose keys are set by rule_type. A "Grade" payload is {"op": "gte" | "lte" | "eq", "value": a fraction from 0.0 to 1.0, "scale": "percent"}.')),
('archived', models.BooleanField(default=False, help_text='Hides a profile from authoring and from new associations while keeping it queryable, so criteria already assigned to it stay resolvable.')),
('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)),
('competency_taxonomy', models.ForeignKey(blank=True, db_constraint=False, help_text='The competency taxonomy this profile is scoped to, if any.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='openedx_learning.competencytaxonomy')),
('course', models.ForeignKey(blank=True, db_constraint=False, help_text='The course run this profile is scoped to, 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)),
('organization', models.ForeignKey(blank=True, db_constraint=False, help_text='The organization this profile is scoped to, if any.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='organizations.organization')),
],
options={
'verbose_name': 'historical competency rule profile',
'verbose_name_plural': 'historical competency rule profiles',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': ('history_date', 'history_id'),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel(
name='CompetencyRuleProfile',
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')),
('scope_code', models.CharField(editable=False, help_text='Derived from organization/course/competency_taxonomy; null while archived, otherwise "org:X,course:Y,taxonomy:Z" with each segment blank when that scope column is null.', max_length=255, null=True)),
('rule_type', models.CharField(choices=[('Grade', 'Grade')], max_length=32)),
('rule_payload', models.JSONField(help_text='Structured payload whose keys are set by rule_type. A "Grade" payload is {"op": "gte" | "lte" | "eq", "value": a fraction from 0.0 to 1.0, "scale": "percent"}.')),
('archived', models.BooleanField(default=False, help_text='Hides a profile from authoring and from new associations while keeping it queryable, so criteria already assigned to it stay resolvable.')),
('competency_taxonomy', models.ForeignKey(blank=True, help_text='The competency taxonomy this profile is scoped to, if any.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='rule_profiles', to='openedx_learning.competencytaxonomy')),
('course', models.ForeignKey(blank=True, help_text='The course run this profile is scoped to, if any.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='competency_rule_profiles', to='openedx_catalog.courserun')),
('organization', models.ForeignKey(blank=True, help_text='The organization this profile is scoped to, if any.', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='competency_rule_profiles', to='organizations.organization')),
],
options={
'constraints': [models.UniqueConstraint(fields=('scope_code',), name='oel_cbe_ruleprofile_scope_code_uniq'), models.CheckConstraint(condition=models.Q(models.Q(('course__isnull', True), ('organization__isnull', True)), models.Q(('competency_taxonomy__isnull', True), ('organization__isnull', True)), models.Q(('competency_taxonomy__isnull', True), ('course__isnull', True)), _connector='OR'), name='oel_cbe_ruleprofile_scope_check', violation_error_message='A CompetencyRuleProfile may be scoped to at most one of organization, course, and competency_taxonomy.'), models.CheckConstraint(condition=models.Q(models.Q(('archived', True), ('scope_code__isnull', True)), models.Q(('archived', False), ('scope_code__isnull', False)), _connector='OR'), name='oel_cbe_ruleprofile_archived_scope_code_check', violation_error_message='An archived CompetencyRuleProfile must have a null scope_code; a live one must not.')],
},
),
]
49 changes: 49 additions & 0 deletions src/openedx_learning/migrations/0005_seed_default_rule_profile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""
Seed the system-default CompetencyRuleProfile: the one row where every scope column is null.

Per ADR-0002 Decision 3, this is the rule every CompetencyCriterion falls back to when nothing
more specific applies, so a deployment that adds no profiles of its own still gets an 80%
threshold.
"""
from django.db import migrations

# Fixed rather than uuid.uuid4(), so this shared system-default row has the same external
# identifier in every deployment, not a fresh random one each time this migration runs.
_DEFAULT_RULE_PROFILE_UUID = "5b3e8f5c-3b0e-4b1a-9b1e-6b6b6b6b6b6b"


def seed_default_rule_profile(apps, schema_editor):
"""Create the all-null-scope CompetencyRuleProfile."""
CompetencyRuleProfile = apps.get_model("openedx_learning", "CompetencyRuleProfile")
CompetencyRuleProfile.objects.create(
uuid=_DEFAULT_RULE_PROFILE_UUID,
rule_type="Grade",
rule_payload={"op": "gte", "value": 0.8, "scale": "percent"},
archived=False,
# apps.get_model() returns a historical model reconstructed from migration state, which
# does not carry CompetencyRuleProfile's custom save() (and so never computes this).
# organization_id/course_id/competency_taxonomy_id are all null for this row, so every
# segment of the "org:X,course:Y,taxonomy:Z" format is blank.
scope_code="org:,course:,taxonomy:",
)


def remove_default_rule_profile(apps, schema_editor):
"""Delete the all-null-scope CompetencyRuleProfile, reversing seed_default_rule_profile."""
CompetencyRuleProfile = apps.get_model("openedx_learning", "CompetencyRuleProfile")
CompetencyRuleProfile.objects.filter(
organization__isnull=True,
course__isnull=True,
competency_taxonomy__isnull=True,
).delete()


class Migration(migrations.Migration):

dependencies = [
("openedx_learning", "0004_competencyruleprofile"),
]

operations = [
migrations.RunPython(seed_default_rule_profile, remove_default_rule_profile),
]
19 changes: 18 additions & 1 deletion tests/openedx_learning/applets/cbe/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from organizations.models import Organization

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


Expand All @@ -15,6 +15,13 @@ def _organization() -> Organization:
return Organization.objects.get(short_name="Org1")


@pytest.fixture(name="organization2")
def _organization2() -> Organization:
"""A second Organization, distinct from `organization`, for use as a scope in these tests."""
ensure_organization("Org2")
return Organization.objects.get(short_name="Org2")


@pytest.fixture(name="course_run")
def _course_run(organization: Organization) -> CourseRun:
"""A CourseRun for use as a scope in these tests."""
Expand All @@ -38,3 +45,13 @@ def _tag(competency_taxonomy: CompetencyTaxonomy) -> Tag:
def _group(tag: Tag) -> CompetencyCriteriaGroup:
"""A root CompetencyCriteriaGroup for `tag`, for use as a criterion's parent group."""
return CompetencyCriteriaGroup.objects.create(tag=tag)


@pytest.fixture(name="default_rule_profile")
def _default_rule_profile() -> CompetencyRuleProfile:
"""The system-default CompetencyRuleProfile seeded by migration 0005."""
return CompetencyRuleProfile.objects.get(
organization__isnull=True,
course__isnull=True,
competency_taxonomy__isnull=True,
)
Loading