Repository navigation
feat: evaluate and record a learner's leaf competency status for a grade #862
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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, | ||
|
|
@@ -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: | ||
| """ | ||
| 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: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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, | ||
| ) | ||
|
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 | ||
| 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}") |
| 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, | ||
| ) |
Uh oh!
There was an error while loading. Please reload this page.