You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
[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
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__.
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 .importlinterroot_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
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
GradeResultshape must match what that listener calls. Confirm against the actuallistener 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 isgreenfield (
src/holds onlyopenedx_catalog,openedx_content,openedx_core,openedx_django_lib,openedx_tagging; noopenedx_learning). The data model(
CompetencyCriteria,CompetencyCriteriaGroup,CompetencyRuleProfile,CompetencyMasteryStatuses, and the three append-only per-learner status tables) isdefined 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-eventsis 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.pythat the openedx-platform listener (already indevelopment) invokes with a learner id, a content identifier (the opaque
oel_tagging_objecttag.object_idstring), and the grade result supplied by thecaller. In a single transaction the function: (a) finds the
CompetencyCriteriaattached to that content, (b) evaluates each leaf
Gradecriterion against itsresolved rule, (c) rolls leaf results up the AND/OR
CompetencyCriteriaGrouptree byboolean logic, and (d) appends rows to
StudentCompetencyCriteriaStatus,StudentCompetencyCriteriaGroupStatus, andStudentCompetencyStatus— appending onlywhen 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/MasteryLeveldeferred — a non-Grade rule raises
NotImplementedErrorduring MVP); criteria onsections, 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).
Context
live at
src/openedx_learning/applets/cbe/models.pyafter 1.1. Note 1.1's text usesa stale path (
openedx_learning/apps/competencies/); ADR 0001 governs and thecorrect path is
src/openedx_learning/applets/cbe/.that calls this API. Confirm the API signature and
GradeResultshape match what thatlistener already calls before finalizing.
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, thecompetency-level status constraint),
0003-competency-criteria-versioning.rst(append-only: current status = latest row, persist only changes), and the new
0004 (this ticket).
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.pyre-exporting each applet's
api.py.Tagin aCompetencyTaxonomy; the association is anoel_tagging_objecttagrow whoseobject_idis an opaque, non-FK string (src/openedx_tagging/models/base.py).Criterion -> one objecttag + rule.
content_taggingpattern in openedx-platform (@receivers inhandlers.pywired fromapps.py.ready(), each enqueuing a Celery task that calls the API). Read the actuallistener to confirm the call site this API must satisfy.
Technical Notes
Files to Create
src/openedx_learning/applets/cbe/api.pyevaluate_and_record_competency_status(...)plus internal resolve/evaluate/recompute helpers. Declares__all__.src/openedx_learning/applets/cbe/data.pyGradeResultdataclass (neutral grade input, MVP fieldearned_percent: Decimal); result/enum aliases.docs/openedx_learning/decisions/0004-competency-mastery-evaluation-api.rsttests/openedx_learning/applets/cbe/test_api.pyFiles to Modify
src/openedx_learning/api.pyfrom .applets.cbe.api import *so callers import fromopenedx_learning.api. Create this facade if ticket 1.1 has not; otherwise extend it.(No
models.py, no migrations, no.importlinter, nopii_checkchanges — all ownedby ticket 1.1. Adding
openedx_learningto.importlinterroot_packages, andclosing the missing
openedx_catalogentry, 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_keymatchesObjectTag.object_idby string equality only (noopaque_keysimport).
GradeResultis a frozen dataclass indata.pycarryingearned_percent: Decimal(named so future rule types extend it without a signaturebreak). Wrap the whole body in one
transaction.atomic().Steps: (1) resolve
CompetencyCriteriawhere the relatedoel_tagging_objecttag.object_id == content_key; (2) per criterion resolve the rulefrom
rule_type_override/rule_payload_overrideif set, else theCompetencyRuleProfilefallback chain taxonomy -> course -> org -> system default(MVP
rule_type == "Grade"only; other types raiseNotImplementedError);(3) evaluate
rule_payload{op, value, scale}againstgrade.earned_percent-> aCompetencyMasteryStatusesvalue (leaf is binary Demonstrated / AttemptedNotDemonstrated);(4) append
StudentCompetencyCriteriaStatusonly if it differs from the learner'slatest row; (5) walk
CompetencyCriteriaGroup.parent_idfrom each affected leaf toroot, recomputing each group over children in
orderingorder with AND/OR booleanshort-circuit (AND = Demonstrated iff all children Demonstrated; OR = iff any;
PartiallyAttempted when some children are unattempted), appending
StudentCompetencyCriteriaGroupStatusonly on change; (6) map the root result toStudentCompetencyStatus(Demonstrated or PartiallyAttempted only — neverAttemptedNotDemonstrated), 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.pycontract); the caller enforces authorization. New code needstests — that is the acceptance bar since there is no QA path.
Example Resolution Prompt
In
openedx-core(repo atopenedx-core/), implement the CBE mastery-evaluationpublic API in the
openedx_learning.applets.cbeapplet. The data model already existsfrom ticket 1.1 per ADR 0002 — do not create or modify models or migrations. Create
src/openedx_learning/applets/cbe/api.pywith one public, keyword-only functionevaluate_and_record_competency_status(*, user_id: int, content_key: str, grade: GradeResult) -> Sequence[StudentCompetencyStatus],declared in
__all__and marked UNSTABLE. Createsrc/openedx_learning/applets/cbe/data.pywith a frozenGradeResultdataclassholding
earned_percent: Decimal. Inside onetransaction.atomic(), the functionmust: (1) find every
CompetencyCriteriawhose relatedoel_tagging_objecttag.object_idstring-equalscontent_key(no opaque_keysparsing); (2) resolve each criterion's rule from
rule_type_override/rule_payload_overridewhen set, else fromCompetencyRuleProfilevia fallback taxonomy -> course -> org -> system default,supporting only
rule_type == "Grade"(raiseNotImplementedErrorforView/MasteryLevel); (3) evaluate the Grade
rule_payload{"op","value","scale"}against
grade.earned_percentto aCompetencyMasteryStatusesvalue; (4) append aStudentCompetencyCriteriaStatusrow only if it differs from the learner's latest rowfor that criterion; (5) walk
CompetencyCriteriaGroup.parent_idfrom each affectedleaf to the root, recomputing each group over its children in
orderingorder withAND/OR boolean short-circuit and appending
StudentCompetencyCriteriaGroupStatusonlyon change; (6) map the root result to
StudentCompetencyStatus, which may only beDemonstratedorPartiallyAttempted(neverAttemptedNotDemonstrated), appendingonly 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.pycovering fallback resolution, eachGrade 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:covandlint-imports.Aspects team is dependant on this being completed so they can work on the instructor portion