Skip to content

[BE] Build listener that checks when an assignment or course is completed and upon completion if there is a competency criteria associated with it, then calculate and update the learner's competency statuses. #643

Description

@thelmick-unicon

Add competency mastery-evaluation API (openedx-core side)

The openedx-platform grade/completion listener that calls this API is already in
development (nearly complete) and is tracked separately. This ticket covers only the
openedx-core API the listener calls, scoped to openedx-core.

Coordination: because the listener already exists, this API's public signature and
GradeResult shape must match what that listener calls. Confirm against the actual
listener code before merge; if they differ, align here or agree a change with the
listener's author.

Status: blocked on ticket 1.1 (CBE data model). Fully specified; ready to implement
once 1.1 merges.

Use Case

As a learner, when I complete gradable content (a course by final grade, or a
subsection assignment) that has a competency criterion attached, I want the platform
to evaluate whether I have demonstrated the associated competency and persist my
mastery status, so that my demonstrated competencies are recorded automatically and
can be surfaced to me, to instructors, and to downstream credentialing without anyone
manually reviewing my grades.

Description

Current state. No CBE code exists in openedx-core/src/ — the feature is
greenfield (src/ holds only openedx_catalog, openedx_content, openedx_core,
openedx_django_lib, openedx_tagging; no openedx_learning). The data model
(CompetencyCriteria, CompetencyCriteriaGroup, CompetencyRuleProfile,
CompetencyMasteryStatuses, and the three append-only per-learner status tables) is
defined by ticket 1.1 and ADRs 0002/0003, which are currently proposals. Grade
and completion signals live in openedx-platform; openedx-core cannot import or consume
them (ADR 0001 rejected alternative #8; migrating grading events to openedx-events
is a listed non-goal). Today nothing turns a grade into a competency status.

Requested change. Add an in-process Python API in
openedx_learning/applets/cbe/api.py that the openedx-platform listener (already in
development) invokes with a learner id, a content identifier (the opaque
oel_tagging_objecttag.object_id string), and the grade result supplied by the
caller. In a single transaction the function: (a) finds the CompetencyCriteria
attached to that content, (b) evaluates each leaf Grade criterion against its
resolved rule, (c) rolls leaf results up the AND/OR CompetencyCriteriaGroup tree by
boolean logic, and (d) appends rows to StudentCompetencyCriteriaStatus,
StudentCompetencyCriteriaGroupStatus, and StudentCompetencyStatus — appending only
when the computed status differs from the learner's latest row. openedx-core owns all
CBE-model knowledge; the platform owns all grading knowledge.

Out of scope (this ticket): the openedx-platform listener/app (already being built
separately); openedx-core consuming events; rule types other than Grade (View/MasteryLevel
deferred — a non-Grade rule raises NotImplementedError during MVP); criteria on
sections, units, or problems (MVP is course + subsection only); course-run-date
windowing; re-evaluation on later criteria changes or regrades, and any lowering of a
status (forward-only per decision — a separate follow-up); any REST/UI surface.

Acceptance Criteria

There is no HTTP or UI surface, so QA/Postman do not apply. Acceptance is determined
by a PR reviewer confirming these scenarios are covered by automated unit tests
exercising the API function directly; each Then names an observable result (return
value or the specific row appended).

Scenario: Leaf Grade criterion met
  Given a CompetencyCriteria on content C with rule {"op":"gte","value":75,"scale":"percent"}
  When the API is called for learner L, content C, with a grade of 80 percent
  Then a StudentCompetencyCriteriaStatus row is appended for (L, that criterion) with status Demonstrated

Scenario: Leaf Grade criterion attempted below threshold
  Given the same criterion
  When the API is called for learner L, content C, with a grade of 60 percent
  Then a StudentCompetencyCriteriaStatus row is appended for (L, that criterion) with status AttemptedNotDemonstrated

Scenario: Content has no associated criteria (clean no-op)
  Given content C with no CompetencyCriteria attached
  When the API is called for learner L, content C, with any grade
  Then no rows are appended to any status table
  And the call returns without error

Scenario: Rule resolved via CompetencyRuleProfile fallback chain
  Given a criterion with no rule override and a CompetencyRuleProfile at the taxonomy level
  When the API evaluates that criterion
  Then the taxonomy-level profile is used (fallback order taxonomy -> course -> org -> system default)

Scenario: AND group fully satisfied
  Given a CompetencyCriteriaGroup with logic_operator AND over two leaf criteria, both Demonstrated for L
  When the group is evaluated
  Then a StudentCompetencyCriteriaGroupStatus row is appended with status Demonstrated
  And a StudentCompetencyStatus row is appended with status Demonstrated

Scenario: AND group partially attempted
  Given an AND group over two leaf criteria where one is Demonstrated and one has no status yet for L
  When the group is evaluated
  Then a StudentCompetencyCriteriaGroupStatus row is appended with status PartiallyAttempted
  And the StudentCompetencyStatus row appended is PartiallyAttempted

Scenario: OR group satisfied by one child
  Given an OR group over two leaf criteria where at least one is Demonstrated for L
  When the group is evaluated
  Then a StudentCompetencyCriteriaGroupStatus row is appended with status Demonstrated

Scenario: Competency level never records AttemptedNotDemonstrated
  Given a top-level group that evaluates to AttemptedNotDemonstrated for L (all attempted, rule unmet)
  When the competency-level status is written
  Then no StudentCompetencyStatus row is written (the table is constrained to Demonstrated/PartiallyAttempted)
  And no database constraint is violated

Scenario: Duplicate identical evaluation is suppressed
  Given learner L already has a latest StudentCompetencyCriteriaStatus of Demonstrated for a criterion
  When the API is called again with a grade that yields Demonstrated
  Then no new row is appended for that criterion (append only on change)

Scenario: One content item feeding multiple competencies
  Given content C referenced by criteria in two different competencies for L
  When the API is called for L, content C, with a grade
  Then both competencies' trees are recomputed and their status rows appended as applicable

Scenario: Non-Grade rule type during MVP
  Given a resolved criterion whose rule_type is View or MasteryLevel
  When the API evaluates it
  Then NotImplementedError is raised (only Grade is supported in MVP)

Context

  • Blocking dependency: ticket 1.1 (CBE data model, ADRs 0002/0003). All models
    live at src/openedx_learning/applets/cbe/models.py after 1.1. Note 1.1's text uses
    a stale path (openedx_learning/apps/competencies/); ADR 0001 governs and the
    correct path is src/openedx_learning/applets/cbe/.
  • Consumer: the openedx-platform grade/completion listener (already in development)
    that calls this API. Confirm the API signature and GradeResult shape match what that
    listener already calls before finalizing.
  • ADRs to read first: docs/openedx_learning/decisions/0001-competency-criteria-location.rst
    (rejected alt refactor: simplify app names #8 — why the listener is not here),
    0002-competency-criteria-model.rst (the model, the bottom-up recompute, the
    competency-level status constraint), 0003-competency-criteria-versioning.rst
    (append-only: current status = latest row, persist only changes), and the new
    0004 (this ticket).
  • Prior art — public API pattern: src/openedx_tagging/api.py (module docstring:
    callers use the API, not the models; no permission enforcement here — the caller
    authorizes) and the applet-facade pattern in src/openedx_content/api.py
    re-exporting each applet's api.py.
  • Prior art — content <-> competency mapping: a competency is a Tag in a
    CompetencyTaxonomy; the association is an oel_tagging_objecttag row whose
    object_id is an opaque, non-FK string (src/openedx_tagging/models/base.py).
    Criterion -> one objecttag + rule.
  • Related — platform listener (in development, not this ticket): it follows the
    content_tagging pattern in openedx-platform (@receivers in handlers.py wired from
    apps.py.ready(), each enqueuing a Celery task that calls the API). Read the actual
    listener to confirm the call site this API must satisfy.

Technical Notes

Files to Create

File Purpose
src/openedx_learning/applets/cbe/api.py Public applet API: evaluate_and_record_competency_status(...) plus internal resolve/evaluate/recompute helpers. Declares __all__.
src/openedx_learning/applets/cbe/data.py Frozen GradeResult dataclass (neutral grade input, MVP field earned_percent: Decimal); result/enum aliases.
docs/openedx_learning/decisions/0004-competency-mastery-evaluation-api.rst Proposed ADR: grading-agnostic API contract, entry-point signature, idempotency-via-change-detection. Prose, matching 0001-0003.
tests/openedx_learning/applets/cbe/test_api.py Tests: rule resolution + fallback chain, Grade-rule evaluation, AND/OR short-circuit recompute, append-only change-detection (redelivery is a no-op), competency-level status mapping, non-Grade raises.

Files to Modify

File Nature of modification
src/openedx_learning/api.py Re-export the public function via from .applets.cbe.api import * so callers import from openedx_learning.api. Create this facade if ticket 1.1 has not; otherwise extend it.

(No models.py, no migrations, no .importlinter, no pii_check changes — all owned
by ticket 1.1. Adding openedx_learning to .importlinter root_packages, and
closing the missing openedx_catalog entry, is 1.1's work.)

Implementation Notes

Recommended approach: a synchronous evaluate-and-write API. The platform-side
receiver + Celery task is already being built separately; this repo stays
grading-agnostic and Celery-free. Match this API's signature to what that in-flight
listener calls (confirm against its call site before merge).

Single public entry point in applets/cbe/api.py, keyword-only per house style:
evaluate_and_record_competency_status(*, user_id: int, content_key: str, grade: GradeResult) -> Sequence[StudentCompetencyStatus].
content_key matches ObjectTag.object_id by string equality only (no opaque_keys
import). GradeResult is a frozen dataclass in data.py carrying
earned_percent: Decimal (named so future rule types extend it without a signature
break). Wrap the whole body in one transaction.atomic().

Steps: (1) resolve CompetencyCriteria where the related
oel_tagging_objecttag.object_id == content_key; (2) per criterion resolve the rule
from rule_type_override/rule_payload_override if set, else the
CompetencyRuleProfile fallback chain taxonomy -> course -> org -> system default
(MVP rule_type == "Grade" only; other types raise NotImplementedError);
(3) evaluate rule_payload {op, value, scale} against grade.earned_percent -> a
CompetencyMasteryStatuses value (leaf is binary Demonstrated / AttemptedNotDemonstrated);
(4) append StudentCompetencyCriteriaStatus only if it differs from the learner's
latest row; (5) walk CompetencyCriteriaGroup.parent_id from each affected leaf to
root, recomputing each group over children in ordering order with AND/OR boolean
short-circuit (AND = Demonstrated iff all children Demonstrated; OR = iff any;
PartiallyAttempted when some children are unattempted), appending
StudentCompetencyCriteriaGroupStatus only on change; (6) map the root result to
StudentCompetencyStatus (Demonstrated or PartiallyAttempted only — never
AttemptedNotDemonstrated), appending only on change.

Idempotency is the ADR-0003 "current = latest row, persist only changes" rule:
redelivery of the same grade computes the same status and appends nothing. Concurrency
(two same-user events at once) is a known edge; serialization is the platform Celery
task's job (per-user routing), not this API's.

Ship the entry point marked UNSTABLE so the input shape can absorb View/MasteryLevel
rule types during MVP without a DEPR cycle. No permission checks here (per the
openedx_tagging/api.py contract); the caller enforces authorization. New code needs
tests — that is the acceptance bar since there is no QA path.

Example Resolution Prompt

In openedx-core (repo at openedx-core/), implement the CBE mastery-evaluation
public API in the openedx_learning.applets.cbe applet. The data model already exists
from ticket 1.1 per ADR 0002 — do not create or modify models or migrations. Create
src/openedx_learning/applets/cbe/api.py with one public, keyword-only function
evaluate_and_record_competency_status(*, user_id: int, content_key: str, grade: GradeResult) -> Sequence[StudentCompetencyStatus],
declared in __all__ and marked UNSTABLE. Create
src/openedx_learning/applets/cbe/data.py with a frozen GradeResult dataclass
holding earned_percent: Decimal. Inside one transaction.atomic(), the function
must: (1) find every CompetencyCriteria whose related
oel_tagging_objecttag.object_id string-equals content_key (no opaque_keys
parsing); (2) resolve each criterion's rule from
rule_type_override/rule_payload_override when set, else from
CompetencyRuleProfile via fallback taxonomy -> course -> org -> system default,
supporting only rule_type == "Grade" (raise NotImplementedError for
View/MasteryLevel); (3) evaluate the Grade rule_payload {"op","value","scale"}
against grade.earned_percent to a CompetencyMasteryStatuses value; (4) append a
StudentCompetencyCriteriaStatus row only if it differs from the learner's latest row
for that criterion; (5) walk CompetencyCriteriaGroup.parent_id from each affected
leaf to the root, recomputing each group over its children in ordering order with
AND/OR boolean short-circuit and appending StudentCompetencyCriteriaGroupStatus only
on change; (6) map the root result to StudentCompetencyStatus, which may only be
Demonstrated or PartiallyAttempted (never AttemptedNotDemonstrated), appending
only on change. Never import from openedx-platform, opaque_keys, or grading; enforce no
permissions (callers do). Re-export the function from src/openedx_learning/api.py.
Add tests/openedx_learning/applets/cbe/test_api.py covering fallback resolution, each
Grade operator, nested AND/OR short-circuit, redelivery-is-a-no-op idempotency, the
competency-level status constraint, and non-Grade raising. Verify with
pytest tests/openedx_learning/applets/cbe/ -p no:cov and lint-imports.


Aspects team is dependant on this being completed so they can work on the instructor portion

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    Status
    Todo

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions