Skip to content
Closed
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
4 changes: 4 additions & 0 deletions .annotation_safe_list.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ openedx_content.UnitVersion:
".. no_pii:": "This model has no PII"
openedx_learning.HistoricalCompetencyCriteriaGroup:
".. no_pii:": "This model has no PII"
openedx_learning.HistoricalCompetencyCriterion:
".. 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
6 changes: 5 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,15 @@
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, CompetencyCriterion, CompetencyRuleProfile, LogicOperator

__all__ = [
"CompetencyCriteriaGroup",
"CompetencyCriterion",
"CompetencyRuleProfile",
"CompetencyTaxonomy",
"LogicOperator",
"RuleType",
]
261 changes: 256 additions & 5 deletions src/openedx_learning/applets/cbe/models/criteria.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,31 @@
"""
The CompetencyAchievementCriteria tree: CompetencyCriteriaGroup, the internal AND/OR node.
The CompetencyAchievementCriteria models: CompetencyCriteriaGroup, CompetencyRuleProfile, and
CompetencyCriterion.

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, 3 and 4 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 openedx_tagging.models import ObjectTag, Tag

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

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

Expand Down Expand Up @@ -101,3 +110,245 @@ class Meta:
# No constraint tying `logic_operator` to child count, and no UniqueConstraint on (parent,
# ordering): a child group cannot be saved until its parent's primary key exists, so
# neither has a single-row state to check at save time. See ADR-0002 Decision 2.


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 keyed by rule_type; see validate_rule_payload for the shape it must match.")
)
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)


class CompetencyCriterion(models.Model):
"""
A leaf node in a CompetencyAchievementCriteria tree: one tag/object association plus its rule.

A null ``rule_profile`` does NOT mean "resolve the applicable profile at read time." ADR-0002
Decision 4 resolves which profile (or override) applies at four specific write events
(creation, a more specific profile appearing later, an author setting a per-criterion
override, and an override being cleared back to matching the computed profile), and stores
the result. ``rule_profile`` is null only when an author has set a per-criterion override; in
every other case it holds the id of the profile that was resolved at the relevant write event
and is never re-resolved dynamically. Do not add a property, manager method, or other helper
that recomputes it; that would contradict the ADR.

When ``rule_type_override`` is set, its ``rule_payload_override``'s shape (see
:func:`~openedx_learning.applets.cbe.rule_payloads.validate_rule_payload`) is validated from
``clean()``, reached from both ``objects.create()`` and a plain ``instance.save()`` via
``full_clean()``. A bulk ``QuerySet.update()``, ``bulk_create()``, and a DRF serializer that
writes straight to the database are NOT covered: none of them build or save a model instance,
so ``clean()`` never runs.

.. no_pii:
"""

uuid = immutable_uuid_field()
group = models.ForeignKey(
CompetencyCriteriaGroup,
db_column="competency_criteria_group_id",
on_delete=models.CASCADE,
related_name="criteria",
help_text=_("The CompetencyCriteriaGroup this leaf criterion belongs to."),
)
object_tag = models.ForeignKey(
ObjectTag,
db_column="oel_tagging_objecttag_id",
on_delete=models.CASCADE,
related_name="competency_criteria",
help_text=_("The tag/object association that this criterion evaluates."),
)
rule_profile = models.ForeignKey(
CompetencyRuleProfile,
null=True,
blank=True,
db_column="competency_rule_profile_id",
on_delete=models.RESTRICT,
related_name="criteria",
help_text=_("The profile this criterion uses by default. Null only when overrides are set instead."),
)
rule_type_override = models.CharField(max_length=32, choices=RuleType, null=True, blank=True)
rule_payload_override = models.JSONField(null=True, blank=True)

history = HistoricalRecords()

class Meta:
# No db_table override: the table is Django's default, openedx_learning_competencycriterion.
# verbose_name/verbose_name_plural are set explicitly because Django's default pluralization
# of "CompetencyCriterion" is "competency criterions". See ADR-0002 Decision 4.
verbose_name = _("Competency Criterion")
verbose_name_plural = _("Competency Criteria")
constraints = [
models.CheckConstraint(
condition=(
Q(
rule_profile__isnull=False,
rule_type_override__isnull=True,
rule_payload_override__isnull=True,
)
| Q(
rule_profile__isnull=True,
rule_type_override__isnull=False,
rule_payload_override__isnull=False,
)
),
name="oel_cbe_criterion_profile_xor_override_check",
violation_error_message=_(
"A CompetencyCriterion must have either a rule_profile with no overrides, or both override "
"fields set with no rule_profile. Never both, never neither."
),
),
]

def clean(self):
"""Validate the override rule_payload's shape, when a per-criterion override is set."""
super().clean()
if self.rule_type_override is not None:
validate_rule_payload(self.rule_type_override, self.rule_payload_override)

def save(self, *args, **kwargs):
"""Persist this criterion, after full_clean() re-validates the override payload, if set."""
self.full_clean(validate_unique=False, validate_constraints=False)
super().save(*args, **kwargs)
88 changes: 88 additions & 0 deletions src/openedx_learning/applets/cbe/rule_payloads.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""
Rule payload shapes for CBE evaluation rules, and the validator that checks a raw payload against
the shape its rule_type defines. See :ref:`openedx-learning-adr-0002` Decision 3 for the payload
contract. ``RuleType`` declares exactly the rule types with a shape defined here, so a rule type
can never be offered as a choice without also being saveable. These messages reach an API caller
or admin form, so they must not leak internal class or function names.
"""
from __future__ import annotations

from typing import Any, Callable

from django.core.exceptions import ValidationError
from django.db import models
from django.utils.translation import gettext_lazy as _

__all__ = [
"RuleType",
"validate_rule_payload",
]


class RuleType(models.TextChoices):
"""
The evaluation rule types a CompetencyRuleProfile or CompetencyCriterion override can use.

Declares exactly the rule types with a defined rule_payload shape below, i.e. exactly the keys
of ``_RULE_PAYLOAD_SPECS``: see this module's own docstring for why the two are never allowed
to drift apart.
"""

GRADE = "Grade", _("Grade")


_GRADE_OPERATORS = {"gte", "lte", "eq"}


def _validate_grade_payload(payload: dict) -> None:
"""Validate a Grade payload's op, value, and scale. Keys are already checked."""
if payload["op"] not in _GRADE_OPERATORS:
raise ValidationError(_("The 'op' in a 'Grade' rule_payload must be one of: gte, lte, eq."))
value = payload["value"]
# isinstance(True, int) is True in Python, so a bool needs excluding explicitly.
if isinstance(value, bool) or not isinstance(value, (int, float)) or not 0.0 <= value <= 1.0:
raise ValidationError(
_(
"The 'value' in a 'Grade' rule_payload must be a fraction between 0.0 and 1.0 inclusive "
"(e.g. 0.8 for a passing grade of 80%%), not %(value)r."
)
% {"value": value}
)
if payload["scale"] != "percent":
raise ValidationError(_("The 'scale' in a 'Grade' rule_payload must be 'percent'."))


# The required keys and validator for each rule type that has a defined payload shape. RuleType
# declares exactly these types, so a rule type can never be offered as a choice without being
# saveable. Adding one is an entry here, a validator, and the matching RuleType member.
_RULE_PAYLOAD_SPECS: dict[str, tuple[frozenset[str], Callable[[dict], None]]] = {
RuleType.GRADE: (frozenset({"op", "value", "scale"}), _validate_grade_payload),
}


def validate_rule_payload(rule_type: str, payload: Any) -> None:
"""
Raise ValidationError unless ``payload`` matches the shape ADR-0002 Decision 3 defines for
``rule_type``, including when ``rule_type`` has no defined shape at all.
"""
spec = _RULE_PAYLOAD_SPECS.get(rule_type)
if spec is None:
raise ValidationError(
_("Rule type '%(rule_type)s' is not supported yet; only 'Grade' has a defined rule_payload shape.")
% {"rule_type": rule_type}
)
expected_keys, validate_values = spec
if not isinstance(payload, dict):
raise ValidationError(_("A '%(rule_type)s' rule_payload must be a JSON object.") % {"rule_type": rule_type})
missing = sorted(expected_keys - payload.keys())
unexpected = sorted(payload.keys() - expected_keys)
if missing or unexpected:
raise ValidationError(
_("A '%(rule_type)s' rule_payload has the wrong keys: missing %(missing)s; unexpected %(unexpected)s.")
% {
"rule_type": rule_type,
"missing": ", ".join(missing) or _("none"),
"unexpected": ", ".join(unexpected) or _("none"),
}
)
validate_values(payload)
Loading