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
108 changes: 107 additions & 1 deletion src/openedx_learning/applets/cbe/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,15 @@
"""
from __future__ import annotations

import logging
from datetime import datetime, timezone
from decimal import Decimal
from typing import Any, Sequence

from django.core.exceptions import ValidationError
from django.db import transaction
from django.db import connection, transaction
from django.db.models import QuerySet
from django.db.transaction import TransactionManagementError
from django.utils.translation import gettext_lazy as _
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import UsageKey
Expand All @@ -15,25 +21,33 @@
from openedx_tagging.api import create_taxonomy, get_object_tags, tag_object
from openedx_tagging.models import ObjectTag, Tag, Taxonomy

from .data import GradedObjectScore
from .models import (
CompetencyCriteriaGroup,
CompetencyCriterion,
CompetencyRuleProfile,
CompetencyTaxonomy,
LogicOperator,
MasteryStatus,
StudentCompetencyCriterionStatus,
)
from .rule_payloads import evaluate_rule

__all__ = [
"GradedObjectScore",
"associate_competency_criterion",
"create_competency_criterion",
"create_competency_taxonomy",
"get_competency_rule_profiles",
"is_competency_taxonomy",
"record_graded_object_statuses",
"resolve_competency_tag",
"resolve_supplied_leaf_group",
"select_competency_taxonomies",
]

log = logging.getLogger(__name__)


def create_competency_taxonomy( # pylint: disable=too-many-positional-arguments
name: str,
Expand Down Expand Up @@ -345,3 +359,95 @@ def associate_competency_criterion(
rule_type_override=rule_type_override,
rule_payload_override=rule_payload_override,
)


def _effective_rule(criterion: CompetencyCriterion) -> tuple[str, Any]:
"""Return (rule_type, payload) for whichever of criterion's profile or override is set."""
if criterion.rule_profile_id is not None:
# The oel_cbe_criterion_profile_xor_override_check constraint guarantees rule_profile is
# set whenever rule_profile_id is; this assertion only narrows that fact for mypy.
assert criterion.rule_profile is not None
return criterion.rule_profile.rule_type, criterion.rule_profile.rule_payload
# Same constraint, the other way around: rule_type_override is set whenever rule_profile_id
# is not.
assert criterion.rule_type_override is not None
return criterion.rule_type_override, criterion.rule_payload_override


def _raise_leaf_status(user_id: int, criterion_id: int, status: MasteryStatus, now: datetime) -> int:
"""
Create or raise a learner's leaf status row for one criterion; never lowers a stored status.

Returns the count of rows created or raised (0 or 1), matching the semantics of
``QuerySet.update()``'s own return value.
"""
row, created = StudentCompetencyCriterionStatus.objects.get_or_create(
user_id=user_id, criterion_id=criterion_id,
defaults={"status_id": status, "created": now, "modified": now},
)
if created:
return 1
return StudentCompetencyCriterionStatus.objects.filter(
pk=row.pk, status_id__lt=status,
).update(status_id=status, modified=now)


def record_graded_object_statuses(*, user_id: int, scores: Sequence[GradedObjectScore]) -> int:
Comment thread
ormsbee marked this conversation as resolved.
"""
Record a learner's leaf-level competency status for each graded object in ``scores``.

UNSTABLE: the rollup and learner-status read entry points still to come may change what this
function returns without a deprecation cycle.

Call this only from inside the same transaction that also writes the grade: this function opens
no transaction of its own and lets any storage error propagate rather than swallowing it, so the
grade and its leaf statuses always commit or roll back together. Pass each ``object_id`` at most
once, using the exact string already stored in ``ObjectTag.object_id``; the match performed
against that value is case-sensitive. Leave out of ``scores`` any object worth zero possible
points and any object the learner has not attempted. Only criteria whose tag belongs to a
competency taxonomy are evaluated, and archived criteria are skipped. An evaluated criterion's
status is only ever raised, never lowered. The return value is the count of leaf rows created or
raised, where ``0`` means nothing changed. This function performs no authorization check of its
own; as with ``openedx_tagging.api``, the caller is responsible for enforcing permissions.

:raises TransactionManagementError: if called outside an atomic block.
"""
# In autocommit mode get_or_create() would commit a leaf row on its own, apart from the grade.
if not connection.in_atomic_block:

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.

Please add a test for this, since it's the one piece of new behavior this round without one. Claude checked that a @pytest.mark.django_db(transaction=True) test sees connection.in_atomic_block as False and gets the TransactionManagementError, and that the same call inside transaction.atomic() goes through, so it's a few lines.

raise TransactionManagementError("record_graded_object_statuses() must run inside the grade's transaction.")
if not scores:
return 0

fraction_by_object_id: dict[str, Decimal] = {}
for score in scores:
if score.object_id in fraction_by_object_id:
raise ValueError(f"duplicate object_id in scores: {score.object_id!r}")
fraction_by_object_id[score.object_id] = score.fraction

criteria = (
CompetencyCriterion.objects.filter(
object_tag__object_id__in=list(fraction_by_object_id),
object_tag__taxonomy__competencytaxonomy__isnull=False,
archived=False,
)
.select_related("object_tag", "rule_profile")
.order_by("pk")
)

now = datetime.now(tz=timezone.utc)
changed = 0
for criterion in criteria:
fraction = fraction_by_object_id[criterion.object_tag.object_id]
rule_type, payload = _effective_rule(criterion)
try:
demonstrated = evaluate_rule(rule_type, payload, fraction)
except ValidationError as exc:
log.warning(
"Skipping CompetencyCriterion %s for user %s on object %s: its rule cannot be applied: %s",
criterion.pk, user_id, criterion.object_tag.object_id, exc.messages,
)
Comment thread
ormsbee marked this conversation as resolved.
continue
status = MasteryStatus.DEMONSTRATED if demonstrated else MasteryStatus.ATTEMPTED_NOT_DEMONSTRATED
changed += _raise_leaf_status(user_id, criterion.pk, status, now)

return changed
38 changes: 38 additions & 0 deletions src/openedx_learning/applets/cbe/data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""
Value objects passed across the CBE applet's public API boundary.
"""
from __future__ import annotations

from dataclasses import dataclass
from decimal import Decimal


@dataclass(frozen=True, kw_only=True, slots=True)
class GradedObjectScore:
"""
One learner's final, override-adjusted fraction earned on one graded object.

``fraction`` is the caller's final answer for how much of ``object_id`` this learner earned;
this library cannot detect whether an override was already folded into it. Leave an object
worth zero possible points out of the list entirely rather than passing it with a
``Decimal("0")`` fraction, since it has no meaningful fraction to evaluate. ``Decimal("0")``
itself is a valid, meaningful input: it means the learner attempted the object and earned
nothing on it.

``fraction`` must be built from a string or from ``Decimal`` arithmetic, never from a
``float``: ``Decimal(0.7)`` is a ``Decimal`` instance, so it passes this class's own type
check, but its value is ``0.69999999999999995559...``, not ``0.7``.
"""

object_id: str
fraction: Decimal

def __post_init__(self) -> None:
"""Enforce this class's type and range contract once, at construction time."""
if not isinstance(self.fraction, Decimal):
raise TypeError(f"fraction must be a Decimal, not {type(self.fraction)!r}")
# is_finite() must run first: 0 <= Decimal("NaN") raises decimal.InvalidOperation, not
# ValueError, so checking finiteness first is what makes every invalid input, including
# NaN and Infinity, actually raise the documented ValueError.
if not self.fraction.is_finite() or not 0 <= self.fraction <= 1:
raise ValueError(f"fraction must be finite and between 0 and 1 inclusive, not {self.fraction!r}")
38 changes: 35 additions & 3 deletions src/openedx_learning/applets/cbe/rule_payloads.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@
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.
or admin form, so they must not leak internal class or function names. ``evaluate_rule()`` applies a
validated payload to a learner's score fraction and returns whether the rule is met.
"""
Comment thread
ormsbee marked this conversation as resolved.
from __future__ import annotations

from typing import Literal, TypedDict, get_args
import operator
from decimal import Decimal
from typing import Callable, Literal, TypedDict

from django.core.exceptions import ValidationError
from django.db import models
Expand All @@ -16,6 +19,7 @@
__all__ = [
"GradeRulePayload",
"RuleType",
"evaluate_rule",
"validate_rule_payload",
]

Expand All @@ -35,7 +39,15 @@ class RuleType(models.TextChoices):

GradeOperator = Literal["gte", "lte", "eq"]

_GRADE_OPERATORS: frozenset[str] = frozenset(get_args(GradeOperator))
# The single source of truth for which operators exist: _GRADE_OPERATORS is derived from this
# map's keys, rather than independently from get_args(GradeOperator), so validate_rule_payload
# and evaluate_rule can never drift apart on which operators are supported.
_GRADE_OPERATOR_FUNCS: dict[str, Callable[[Decimal, Decimal], bool]] = {
"gte": operator.ge,
"lte": operator.le,
"eq": operator.eq,
}
_GRADE_OPERATORS: frozenset[str] = frozenset(_GRADE_OPERATOR_FUNCS)


class GradeRulePayload(TypedDict):
Expand Down Expand Up @@ -107,3 +119,23 @@ def validate_rule_payload(rule_type: str, payload: object) -> None:
_("Rule type '%(rule_type)s' is not supported yet; only 'Grade' has a defined rule_payload shape.")
% {"rule_type": rule_type}
)


def evaluate_rule(rule_type: str, payload: object, fraction: Decimal) -> bool:
"""
Return whether ``fraction`` satisfies ``rule_type``'s ``payload``.

Validates the payload's shape first (see :func:`validate_rule_payload`); raises
``ValidationError`` and never returns a value when the shape is invalid, or when
``rule_type`` has no defined evaluation yet (today, everything except ``RuleType.GRADE``).
"""
validate_rule_payload(rule_type, payload)
if rule_type != RuleType.GRADE:
raise ValidationError(
_("no evaluator is defined yet for rule type '%(rule_type)s'") % {"rule_type": rule_type}
)
# validate_rule_payload already confirmed payload is a dict with a Grade payload's exact
# keys; this assertion only narrows that fact for mypy, which sees payload as `object`.
assert isinstance(payload, dict)
threshold = Decimal(str(payload["value"]))
return _GRADE_OPERATOR_FUNCS[payload["op"]](fraction, threshold)
17 changes: 13 additions & 4 deletions src/openedx_learning/models_api.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
"""
Models that we want callers to extend or make foreign keys to.
Models that we want callers to extend, make foreign keys to, or use in type annotations.

This is also the stable import point for the model class itself, for callers that
need to create competency taxonomies directly.
Other apps should create and change these rows, including test data, only through
`openedx_learning.api`, which keeps the invariants that direct model writes would skip.
"""

# pylint: disable=unused-import
from .models import CompetencyTaxonomy
from .models import (
CompetencyCriteriaGroup,
CompetencyCriterion,
CompetencyMasteryStatus,
CompetencyRuleProfile,
CompetencyTaxonomy,
MasteryStatus,
RuleType,
StudentCompetencyCriterionStatus,
)
Loading
Loading