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
Blocked by#613 (the CBE data model): CompetencyCriteriaGroup/CompetencyCriterion exist only as approved design in ADR 0002, not yet in code.
Repo: openedx-core (endpoint plus a constraints migration) and openedx-platform (CMS-side wiring).
Use Case
As a course author associating (or re-associating) a gradable subsection with a competency in a course, via the Manage & Apply Competencies authoring flow (#671), I want the system to attach a Competency Criterion, either into a specific existing Criteria Group I already have in hand, or by deriving and creating whatever part of the group hierarchy (root, course-level, leaf) doesn't yet exist for this competency and course, so that this subsection's grade becomes a concrete, evaluable rule toward the learner's mastery of this competency, without me ever having to see or manage the underlying group structure unless I am explicitly targeting one.
Description
This ticket adds a single POST endpoint that creates a CompetencyCriterion, either by attaching it into an already-existing leaf CompetencyCriteriaGroup or by deriving/creating whatever part of the 3-level group hierarchy (root, course-level, leaf) doesn't already exist, in one request/response contract.
Current state
No endpoint exists today to create a CompetencyCriterion. Per ADR 0002, CompetencyCriteriaGroup is a self-referential tree node (id, nullable self-FK parent_id, oel_tagging_tag_id FK to the competency tag, nullable course_id FK to a course run, name, ordering, logic_operator), and CompetencyCriterion is the leaf rule (id, competency_criteria_group_id FK, oel_tagging_objecttag_id FK to the tag/object association, nullable competency_rule_profile_id, nullable rule_type_override/rule_payload_override).
Requested change
POST /cbe/rest_api/v1/competencies/<int:tag_id>/criteria/ accepts a required object_id (subsection usage-key string) and an optional group_id (an existing leaf group's id). There is no separate course_id or taxonomy_id field: the course is derived by parsing object_id and resolving its course key to a CourseRun, and the taxonomy is derived from tag_id alone.
If group_id is supplied, the endpoint validates it names a leaf group belonging to the right competency and course, then attaches the criterion directly and creates no groups. If group_id is omitted, the endpoint gets-or-creates the root and course-level groups for this competency and course, always creates a fresh leaf under them, and attaches the criterion there. Root and course-level resolution is race-safe via two new partial UniqueConstraints on CompetencyCriteriaGroup (one root per competency; one course-level group per competency+course), rather than select_for_update or catch-and-retry, neither of which appears in this codebase. See Technical Details for the exact validation order and status codes.
An optional logic_operator field is accepted alongside object_id, defaulting to "OR" when omitted. It applies only on the derive-or-create path, to the newly created leaf group (the leaf is a fresh row every time group_id is omitted, so this never overwrites another criterion's group). Supplying logic_operator together with group_id is rejected with a 400: an existing leaf group's logic_operator was set when the group was created, and updating it is #675's scope, not this endpoint's, so the two fields are mutually exclusive rather than one silently overriding or being ignored by the other. This closes a gap where the calling UI's AND/OR choice for a new leaf group had nowhere to go and was silently discarded.
competency_rule_profile_id, rule_type_override, and rule_payload_override may all be omitted together in the same request: that is a valid, complete request, not an incomplete one. The created CompetencyCriterion row is persisted with all three fields null, and that all-null state is itself the signal that the system default competency rule profile applies. This ticket does not resolve or store a concrete default profile at creation time; it only accepts and persists the all-null state as meaningful. Interpreting null as "apply the system default" is a downstream evaluation-time concern, out of scope here.
Either way, tagging the object is a read-merge-write: the object's existing tags in this taxonomy are preserved and the competency tag is unioned in, never overwritten.
The endpoint also rejects creating a second CompetencyCriterion for a tag_id/object_id pairing that already has one, with a 400, whether the request re-targets the same leaf group, targets a different existing leaf group, or would otherwise go through the derive-or-create path. This check runs before any group creation, so a rejected duplicate never leaves an orphaned empty group behind (ADR 0002 forbids persisting empty groups).
The endpoint rejects a tag_id that isn't a Competency Taxonomy tag (404), and requires oel_tagging.can_tag_object: studio write access to the subsection's course plus taxonomy view access, not a staff-only check, since the actor is an ordinary course author tagging their own content.
This ticket also adds the openedx-platform wiring (INSTALLED_APPS entries, the cms/urls.py include) needed to reach this REST API surface, since no other ticket does: an earlier standalone group-creation endpoint (#664, #646) was superseded by this ticket resolving or creating the group hierarchy internally, so there's no separate ticket left to own that wiring.
Parent-competency dominance validation. Tracked in [BE] Enforce competency-hierarchy dominance for Competency Criteria #666, which also resolved that two of the three originally-scoped containment rules (duplicate-path overlap, group/course isolation) don't need separate implementation given this ticket's subsection-only scope. This ticket only reserves the _validate_containment(group, object_id) insertion point.
Validating that object_id refers to a gradeable subsection. Considered and dropped: grading configuration lives in openedx-platform, and openedx-core's own catalog model (CourseRun) explicitly does not yet model grading policy, so there is no data in this repo to validate against. See Open Questions.
Course-level (final-grade) association: this ticket accepts a subsection-usage-key object_id only.
Acceptance Criteria
These scenarios are verifiable via Postman.
Scenario: No group supplied, no existing groups at all for this tag and course
Given a competency exists with no CompetencyCriteriaGroup rows at all
And the requesting user has studio write access to the subsection's course and view access to the competency's taxonomy
When a POST request is sent to the create-criterion endpoint for that competency with a valid gradeable-subsection object_id and no group_id
Then the response returns status code 201
And a root CompetencyCriteriaGroup is created for the competency (course_id null, parent null)
And a course-level CompetencyCriteriaGroup is created, parented under the root, scoped to object_id's parsed course
And a new leaf CompetencyCriteriaGroup is created, parented under the course-level group, named from the course's display name
And a CompetencyCriterion is created, associated with that new leaf CompetencyCriteriaGroup
And the response body's "competency_criteria_group_id" refers to that new leaf group
And the response body includes the criterion's "id", "competency_criteria_group_id", "oel_tagging_objecttag_id", and any supplied "rule_type_override"/"rule_payload_override"
Scenario: No group supplied, logic_operator provided
Given a competency exists with no CompetencyCriteriaGroup rows at all
When a POST request is sent to the create-criterion endpoint with a valid gradeable-subsection object_id, no group_id, and logic_operator "AND"
Then the response returns status code 201
And the newly created leaf CompetencyCriteriaGroup's logic_operator is "AND"
Scenario: No group supplied, logic_operator omitted
Given a competency exists with no CompetencyCriteriaGroup rows at all
When a POST request is sent to the create-criterion endpoint with a valid gradeable-subsection object_id, no group_id, and no logic_operator
Then the response returns status code 201
And the newly created leaf CompetencyCriteriaGroup's logic_operator defaults to "OR"
Scenario: Reject group_id and logic_operator supplied together
Given a leaf CompetencyCriteriaGroup already exists for a competency, scoped to Course X
When a POST request to that competency's endpoint supplies the leaf's group_id, a matching object_id, and a logic_operator
Then the response returns status code 400
And the response body identifies that group_id and logic_operator cannot be supplied together
And no CompetencyCriterion is created
Scenario: No group supplied, a course-level group already exists for this tag and course
Given a competency already has a root group and a course-level group for Course X
When a POST request associates a second gradeable subsection, also in Course X, with the same competency and no group_id
Then the response returns status code 201
And no new root or course-level group is created — the existing course-level group's id is unchanged
And a new leaf CompetencyCriteriaGroup is created as a sibling under that same course-level group
And a CompetencyCriterion is created, associated with that new sibling leaf CompetencyCriteriaGroup
Scenario: No group supplied, a different course than any existing group for this tag
Given a competency already has a root group and a course-level group for Course X
When a POST request associates a gradeable subsection in Course Y (a different course) with the same competency and no group_id
Then the response returns status code 201
And the existing root is reused — no new root group is created
And a new course-level CompetencyCriteriaGroup is created for Course Y, parented under the existing root
And a new leaf CompetencyCriteriaGroup is created under that new Course Y course-level group
And a CompetencyCriterion is created, associated with that new leaf CompetencyCriteriaGroup
Scenario: Group supplied, targeting an existing leaf whose competency and course both match
Given a leaf CompetencyCriteriaGroup already exists for a competency, scoped to Course X
When a POST request to that competency's endpoint supplies the leaf's group_id together with an object_id whose parsed course is Course X
Then the response returns status code 201
And no CompetencyCriteriaGroup rows are created at any level
And a CompetencyCriterion is created, associated with the supplied leaf CompetencyCriteriaGroup
And the response body's "competency_criteria_group_id" matches the supplied group's id
Scenario: Group supplied but resolves to a root or course-level group, not a leaf
Given the supplied group_id belongs to a root or course-level CompetencyCriteriaGroup rather than a leaf
When a POST request supplies that group_id
Then the response returns status code 400
And the response body identifies that only a leaf group may be targeted
Scenario: Group supplied but belongs to a different competency than the URL's tag_id
Given a leaf CompetencyCriteriaGroup exists for competency A
When a POST request to competency B's endpoint supplies competency A's leaf group_id
Then the response returns status code 400
And the response body identifies the group/competency mismatch
Scenario: Group supplied, is a leaf and belongs to the right competency, but its course scope doesn't match the submitted subsection
Given a leaf CompetencyCriteriaGroup is scoped, via its course-level ancestor, to Course X
When a POST request supplies that leaf's group_id together with an object_id whose parsed course is Course Y
Then the response returns status code 400
And the response body identifies the course mismatch
Scenario: Reject duplicate association, same group re-targeted
Given a CompetencyCriterion already exists associating object_id X with tag_id T, attached to leaf group G
When a POST request to competency T's endpoint supplies group_id G together with the same object_id X
Then the response returns status code 400
And the response body identifies the duplicate tag_id/object_id association
And no new CompetencyCriterion is created
Scenario: Reject duplicate association, a different existing leaf group targeted
Given a CompetencyCriterion already exists associating object_id X with tag_id T, attached to leaf group G
And a sibling leaf group G2 also exists for tag_id T under the same course-level group as G
When a POST request to competency T's endpoint supplies group_id G2 together with the same object_id X
Then the response returns status code 400
And the response body identifies the duplicate tag_id/object_id association
And no new CompetencyCriterion is created
Scenario: Reject duplicate association via the derive-or-create path
Given a CompetencyCriterion already exists associating object_id X with tag_id T, attached to some leaf group elsewhere for the same competency
When a POST request to competency T's endpoint supplies object_id X and no group_id
Then the response returns status code 400
And the response body identifies the duplicate tag_id/object_id association
And no new CompetencyCriteriaGroup or CompetencyCriterion rows are created
Scenario: Create a criterion with no rule fields supplied
Given a competency and a valid gradeable-subsection object_id
When a POST request creates a criterion with no competency_rule_profile_id, rule_type_override, or rule_payload_override supplied
Then the response returns status code 201
And the created criterion's "competency_rule_profile_id", "rule_type_override", and "rule_payload_override" are all null
And this all-null state is valid, signaling that the system default competency rule profile applies
Scenario: Reusing a subsection already tagged with a different competency preserves both tags
Given a gradeable subsection is already tagged with competency A via a prior CompetencyCriterion
When a POST request creates a criterion associating that same subsection with competency B
Then the response returns status code 201
And a CompetencyCriterion is created, associated with the resolved leaf CompetencyCriteriaGroup for competency B
And the subsection's object tags still include both competency A and competency B afterward
Scenario: Reject creation for a competency that does not exist
Given the referenced competency tag id does not exist, or exists but is not a tag within a Competency Taxonomy
When a POST request is sent to the create-criterion endpoint referencing that id
Then the response returns status code 404
Scenario: Reject a supplied group_id that does not exist
Given the referenced group_id does not exist
When a POST request supplies that id
Then the response returns status code 404
Scenario: Reject creation with a missing or malformed object_id
Given a POST request omits "object_id", or supplies a value that doesn't parse as a subsection usage key
When the request is processed
Then the response returns status code 400
And the response body identifies the invalid or missing field
Scenario: Reject creation when object_id's parsed course has no matching CourseRun
Given a POST request supplies an object_id that parses as a valid usage key, but its course key resolves to no CourseRun row
When the request is processed
Then the response returns status code 400
And the response body identifies the course as unresolvable
Scenario: Reject creation without permission
Given the requesting user lacks studio write access to the subsection's course, or lacks view access to the taxonomy owning the competency tag
When a POST request is sent to the create-criterion endpoint
Then the response returns status code 403
Open Questions
[non-blocking, owner: implementer] Should the two new UniqueConstraints (one root per competency; one course-level group per competency+course) land in this ticket's own follow-up migration, or fold into [BE] Implement CBE core data models (CompetencyTaxonomy, criteria, learner status) #613's initial migration if it hasn't merged yet? Recommended: an independent follow-up migration here, to keep the tickets decoupled.
[non-blocking, owner: implementer] Naming convention for the root and course-level groups, which are never shown in the UI but likely need a non-null name per ADR 0002's field list. Recommended: a deterministic placeholder such as f"{tag.value} (root)" / f"{tag.value} — {course_run.title}", to aid admin/debug visibility.
[non-blocking, owner: architect/future]object_id currently must resolve to a CourseRun (400 otherwise). Future library-subsection support may need this to loosen; no action needed now.
[non-blocking, owner: architect/future] Validating that object_id is gradeable was dropped: openedx-core has no grading data to check against (CourseGradingPolicy isn't built yet; grading lives in openedx-platform, never imported here). If needed later, it'll need a sync mechanism into openedx_catalog or enforcement at another layer (frontend, or the evaluation handler). Not resolved here.
[non-blocking, owner: architect/future] This ticket builds only the 3-level hierarchy (root/course-level/leaf), no richer author-defined grouping. If added later, resolve_supplied_leaf_group's leaf-shape test (parent_id set, course_id null) breaks, since a new grouping node would have the same shape. Fix needs either an explicit node-type field or a relationship-based check (child groups vs. a CompetencyCriterion). Not resolved here; flagged so a future ticket doesn't rediscover it.
Technical Details
Data Structures
CompetencyCriteriaGroup (ADR 0002 fields, unchanged) gains two partial unique constraints:
UniqueConstraint(fields=["oel_tagging_tag_id"], condition=Q(parent_id__isnull=True))
# at most one root group per competency
UniqueConstraint(fields=["oel_tagging_tag_id", "course_id", "parent_id"], condition=Q(course_id__isnull=False))
# at most one course-level group per (competency, course) under a given parent
These constraints make root/course-level resolution race-safe via plain get_or_create() inside transaction.atomic(), the pattern this repo already uses elsewhere for constraint-backed get_or_create() calls, rather than select_for_update or catch-and-retry, neither of which appears in this codebase. A leaf group never sets course_id and always has a non-null parent_id, so it never matches either constraint's condition: no new depth/level column is needed, since (parent_id, course_id) alone identifies root, course-level, and leaf.
defcreate_competency_criterion(
group: CompetencyCriteriaGroup,
object_id: str,
competency_rule_profile_id: int|None=None,
rule_type_override: str|None=None,
rule_payload_override: dict|None=None,
) ->CompetencyCriterion:
"""Read-merge-write the ObjectTag, call the reserved _validate_containment(group, object_id) seam (implemented by #666, not here), then create and return the CompetencyCriterion row under group."""defresolve_or_create_leaf_group(
tag: Tag, course_run: CourseRun, logic_operator: str|None=None,
) ->CompetencyCriteriaGroup:
"""Get-or-create the root and course-level groups for this competency/course, then always create and return a fresh leaf group parented under the course-level group, with logic_operator set from the argument, defaulting to "OR" if None."""defresolve_supplied_leaf_group(group_id: int, tag: Tag, course_run: CourseRun) ->CompetencyCriteriaGroup:
"""Resolve group_id via get_object_or_404 (this app has no custom exception-handling mixin, so a bare .get() would surface as an unhandled 500, not a 404). Validate, in order: it is a leaf (parent_id not null, course_id null); it belongs to tag; its course-level parent's course_id matches course_run. Raise a field-level 400 on the first failure that doesn't hold; otherwise return the group unchanged."""defassociate_competency_criterion(
tag_id: int,
object_id: str,
group_id: int|None=None,
logic_operator: str|None=None,
competency_rule_profile_id: int|None=None,
rule_type_override: str|None=None,
rule_payload_override: dict|None=None,
) ->CompetencyCriterion:
"""The single public entry point. Resolves the competency Tag and the CourseRun from object_id's parsed course key, rejects a duplicate (tag_id, object_id) association regardless of group_id, then branches on whether group_id was supplied (resolve_supplied_leaf_group vs. resolve_or_create_leaf_group), then calls create_competency_criterion() on the resulting leaf, all inside one transaction.atomic() block. Rejects with a 400 if group_id and logic_operator are both supplied, since logic_operator only ever applies to a leaf group this call creates, and updating an existing leaf's logic_operator is #675's scope. When logic_operator is supplied without group_id, it is threaded into resolve_or_create_leaf_group."""
Logic
Resolve the competency Tag from the URL's tag_id; 404 if missing or not backed by a CompetencyTaxonomy.
Parse object_id via UsageKey.from_string; 400 with a field-level error if it doesn't parse.
Resolve the CourseRun from the parsed key's course key via openedx_catalog.api.get_course_run(); 400 if no matching row. This course_run is the single source of truth for course: it drives both leaf-group naming and the course-scope check, whichever branch runs.
Permission check: build the taxonomy/object-id permission object and check oel_tagging.can_tag_object; 403 on failure. This composite (studio write access to the subsection's course plus taxonomy view access) is already registered globally by openedx-platform's content_tagging app, so no new predicate is needed.
Duplicate check: look up ObjectTag.objects.filter(object_id=object_id, taxonomy_id=tag.taxonomy_id, tag_id=tag.id).first(). If found, check whether any CompetencyCriterion already references it (CompetencyCriterion.objects.filter(oel_tagging_objecttag=existing_object_tag).exists()); if so, reject with a 400 ValidationError (this repo's existing pattern for uniqueness violations — see src/openedx_tagging/rest_api/v1/serializers.py's validate_tag_value; there is no 409 usage anywhere in this codebase's REST API). If no ObjectTag exists yet for this tag on this object, there is nothing to conflict with; proceed. This check runs before any group creation, regardless of whether group_id was supplied, so a duplicate attempt via the derive-or-create path doesn't leave behind an orphaned empty group (ADR 0002 forbids persisting empty groups).
group_id/logic_operator exclusivity check: if both group_id and logic_operator are supplied, reject with a 400 ValidationError naming the conflict, before resolving or creating anything. logic_operator only ever sets a newly created leaf's operator; an existing leaf's operator is [BE] Build endpoint for removing a Competency Criteria Group #675's to change, not this endpoint's, so the two fields together are a client error rather than an ambiguity for this endpoint to resolve on its own.
Branch on group_id: if supplied, call resolve_supplied_leaf_group(); if not, call resolve_or_create_leaf_group(), passing logic_operator through so the newly created leaf's logic_operator column is set from it, defaulting to "OR" if omitted. Both return a CompetencyCriteriaGroup guaranteed to be a valid, course-matched leaf (see Data Structures for the supplied-group validation order).
Inside one transaction.atomic() block covering steps 7-8: call create_competency_criterion() on the resolved/created leaf. Keeping this inside the same atomic block as group resolution matters for the no-group_id branch: if criterion creation fails (a containment violation from [BE] Enforce competency-hierarchy dominance for Competency Criteria #666's seam, an invalid override), the rollback must also undo any newly-created root/course-level/leaf groups, since ADR 0002 forbids persisting empty groups. create_competency_criterion()'s own read-merge-write step will re-resolve the same ObjectTag row checked in step 5 (or find none, on a first association) — an implementer may thread the already-resolved ObjectTag through to avoid a redundant query, but this is an optimization, not a correctness requirement.
Return 201 with the created criterion's representation, including competency_criteria_group_id referring to whichever leaf was used.
Public-API impact: additive functions in applets/cbe/api.py, reusing openedx_tagging.api.get_object_tags/tag_object and openedx_catalog.api.get_course_run unchanged. No breaking changes: greenfield work, no shipped callers. .importlinter needs openedx_catalog and openedx_learning added to its layering contract if #613 hasn't already, since this ticket's course-run lookup crosses into openedx_catalog.
Test strategy: unit tests for associate_competency_criterion covering both branches (derive-or-create: first association, same-course reuse, different-course reuse, a concurrent-request race not producing duplicate root/course-level rows, logic_operator supplied vs. defaulted to "OR"; supplied-group: happy path, non-leaf rejection, ownership-mismatch rejection, course-mismatch rejection, not-found) plus the three duplicate-association rejection cases (same group, different existing group, derive-path) plus the group_id/logic_operator mutual-exclusion rejection plus DRF integration tests for every acceptance criterion above.
Example Resolution Prompt
Implement POST /cbe/rest_api/v1/competencies/<int:tag_id>/criteria/ per the Data Structures and Logic above: request body {object_id, group_id?, logic_operator?, competency_rule_profile_id?, rule_type_override?, rule_payload_override?}. Assume #613 has landed CompetencyCriteriaGroup/CompetencyCriterion matching ADR docs/openedx_learning/decisions/0002-competency-criteria-model.rst; code goes under src/openedx_learning/applets/cbe/ per ADR 0001.
Build associate_competency_criterion() as the single api.py entry point, following the Technical Details signatures and call order exactly: course/tag resolution, the permission check, the duplicate-association check, the group_id branch, then the shared atomic block. One CompetencyCriterionSerializer, no variant needed, with group_id genuinely optional, mirroring the existing optional-field-driven-branching pattern in this repo's TaxonomyTagsView tag-creation body (see Context). Reuse the existing oel_tagging.can_tag_object permission; no new predicate. Do not validate gradeability of object_id — deliberately out of scope, see Open Questions. Do not implement containment/isolation-rule validation — that is #666's scope, not this ticket's.
Verify against every scenario in Acceptance Criteria above.
Companion ticket, openedx-platform (not yet numbered): this ticket's oel_tagging.can_tag_object check (step 4 above) currently resolves course objects through a legacy-only role check that never consults the new authorization service, even after a course has switched over to it, unlike Studio's own tag-editing endpoint, which already handles that switch correctly. The companion ticket fixes the shared permission check itself; this ticket needs no code change once it lands, since has_perm('oel_tagging.can_tag_object', ...) will simply start returning the right answer.
ADR 0002 (docs/openedx_learning/decisions/0002-competency-criteria-model.rst): canonical field lists and tree semantics for both models; its rule against persisting empty groups is why group creation and criterion creation share one transaction.
ADR 0001: places this ticket's code at src/openedx_learning/applets/cbe/.
openedx-platform's content_tagging/rules.py: overrides oel_tagging.can_tag_object with the permission composite this ticket relies on.
src/openedx_tagging/rest_api/v1/views.py's TaxonomyTagsView: precedent for an optional request field (parent_tag_value) branching create logic downstream, the pattern group_id follows.
src/openedx_tagging/rest_api/v1/exception_handlers.py: only APIException/Http404/ PermissionDenied map to a clean response, hence get_object_or_404 over a bare .get() for the supplied-group_id path.
src/openedx_tagging/rest_api/v1/serializers.py's validate_tag_value: this repo's only existing uniqueness-violation precedent, a 400 ValidationError, the pattern the duplicate-association check follows. No 409 usage exists anywhere in this codebase's REST API.
src/openedx_tagging/models/base.py's ObjectTag: unique_together on (object_id, taxonomy, tag_id), which the duplicate-association check relies on.
src/openedx_tagging/api.py (get_object_tags, tag_object): the two public functions this ticket's read-merge-write logic composes.
openedx_catalog.api.get_course_run(): resolves the CourseRun from a course key; also the source of a course's display name for leaf-group naming. See Open Questions for why gradeable-subsection validation isn't checked against it.
unit tests for associate_competency_criterion, resolve_or_create_leaf_group, resolve_supplied_leaf_group
Modified files
File
Nature of modification
src/openedx_learning/applets/cbe/api.py
add resolve_or_create_leaf_group, resolve_supplied_leaf_group, create_competency_criterion, associate_competency_criterion (create the file first if #613 hasn't already)
Blocked by #613 (the CBE data model):
CompetencyCriteriaGroup/CompetencyCriterionexist only as approved design in ADR 0002, not yet in code.Repo:
openedx-core(endpoint plus a constraints migration) andopenedx-platform(CMS-side wiring).Use Case
As a course author associating (or re-associating) a gradable subsection with a competency in a course, via the Manage & Apply Competencies authoring flow (#671), I want the system to attach a Competency Criterion, either into a specific existing Criteria Group I already have in hand, or by deriving and creating whatever part of the group hierarchy (root, course-level, leaf) doesn't yet exist for this competency and course, so that this subsection's grade becomes a concrete, evaluable rule toward the learner's mastery of this competency, without me ever having to see or manage the underlying group structure unless I am explicitly targeting one.
Description
This ticket adds a single POST endpoint that creates a
CompetencyCriterion, either by attaching it into an already-existing leafCompetencyCriteriaGroupor by deriving/creating whatever part of the 3-level group hierarchy (root, course-level, leaf) doesn't already exist, in one request/response contract.Current state
No endpoint exists today to create a
CompetencyCriterion. Per ADR 0002,CompetencyCriteriaGroupis a self-referential tree node (id, nullable self-FKparent_id,oel_tagging_tag_idFK to the competency tag, nullablecourse_idFK to a course run,name,ordering,logic_operator), andCompetencyCriterionis the leaf rule (id,competency_criteria_group_idFK,oel_tagging_objecttag_idFK to the tag/object association, nullablecompetency_rule_profile_id, nullablerule_type_override/rule_payload_override).Requested change
POST /cbe/rest_api/v1/competencies/<int:tag_id>/criteria/accepts a requiredobject_id(subsection usage-key string) and an optionalgroup_id(an existing leaf group's id). There is no separatecourse_idortaxonomy_idfield: the course is derived by parsingobject_idand resolving its course key to aCourseRun, and the taxonomy is derived fromtag_idalone.If
group_idis supplied, the endpoint validates it names a leaf group belonging to the right competency and course, then attaches the criterion directly and creates no groups. Ifgroup_idis omitted, the endpoint gets-or-creates the root and course-level groups for this competency and course, always creates a fresh leaf under them, and attaches the criterion there. Root and course-level resolution is race-safe via two new partialUniqueConstraints onCompetencyCriteriaGroup(one root per competency; one course-level group per competency+course), rather thanselect_for_updateor catch-and-retry, neither of which appears in this codebase. See Technical Details for the exact validation order and status codes.An optional
logic_operatorfield is accepted alongsideobject_id, defaulting to"OR"when omitted. It applies only on the derive-or-create path, to the newly created leaf group (the leaf is a fresh row every timegroup_idis omitted, so this never overwrites another criterion's group). Supplyinglogic_operatortogether withgroup_idis rejected with a 400: an existing leaf group'slogic_operatorwas set when the group was created, and updating it is #675's scope, not this endpoint's, so the two fields are mutually exclusive rather than one silently overriding or being ignored by the other. This closes a gap where the calling UI's AND/OR choice for a new leaf group had nowhere to go and was silently discarded.competency_rule_profile_id,rule_type_override, andrule_payload_overridemay all be omitted together in the same request: that is a valid, complete request, not an incomplete one. The createdCompetencyCriterionrow is persisted with all three fields null, and that all-null state is itself the signal that the system default competency rule profile applies. This ticket does not resolve or store a concrete default profile at creation time; it only accepts and persists the all-null state as meaningful. Interpreting null as "apply the system default" is a downstream evaluation-time concern, out of scope here.Either way, tagging the object is a read-merge-write: the object's existing tags in this taxonomy are preserved and the competency tag is unioned in, never overwritten.
The endpoint also rejects creating a second
CompetencyCriterionfor atag_id/object_idpairing that already has one, with a 400, whether the request re-targets the same leaf group, targets a different existing leaf group, or would otherwise go through the derive-or-create path. This check runs before any group creation, so a rejected duplicate never leaves an orphaned empty group behind (ADR 0002 forbids persisting empty groups).The endpoint rejects a
tag_idthat isn't a Competency Taxonomy tag (404), and requiresoel_tagging.can_tag_object: studio write access to the subsection's course plus taxonomy view access, not a staff-only check, since the actor is an ordinary course author tagging their own content.This ticket also adds the
openedx-platformwiring (INSTALLED_APPSentries, thecms/urls.pyinclude) needed to reach this REST API surface, since no other ticket does: an earlier standalone group-creation endpoint (#664, #646) was superseded by this ticket resolving or creating the group hierarchy internally, so there's no separate ticket left to own that wiring.Explicitly out of scope
CompetencyCriteriaGroup([BE] Build endpoint for removing a Competency Criteria Group #675)._validate_containment(group, object_id)insertion point.object_idrefers to a gradeable subsection. Considered and dropped: grading configuration lives inopenedx-platform, andopenedx-core's own catalog model (CourseRun) explicitly does not yet model grading policy, so there is no data in this repo to validate against. See Open Questions.object_idonly.Acceptance Criteria
These scenarios are verifiable via Postman.
Open Questions
UniqueConstraints (one root per competency; one course-level group per competency+course) land in this ticket's own follow-up migration, or fold into [BE] Implement CBE core data models (CompetencyTaxonomy, criteria, learner status) #613's initial migration if it hasn't merged yet? Recommended: an independent follow-up migration here, to keep the tickets decoupled.nameper ADR 0002's field list. Recommended: a deterministic placeholder such asf"{tag.value} (root)"/f"{tag.value} — {course_run.title}", to aid admin/debug visibility.object_idcurrently must resolve to aCourseRun(400 otherwise). Future library-subsection support may need this to loosen; no action needed now.object_idis gradeable was dropped:openedx-corehas no grading data to check against (CourseGradingPolicyisn't built yet; grading lives inopenedx-platform, never imported here). If needed later, it'll need a sync mechanism intoopenedx_catalogor enforcement at another layer (frontend, or the evaluation handler). Not resolved here.resolve_supplied_leaf_group's leaf-shape test (parent_idset,course_idnull) breaks, since a new grouping node would have the same shape. Fix needs either an explicit node-type field or a relationship-based check (child groups vs. aCompetencyCriterion). Not resolved here; flagged so a future ticket doesn't rediscover it.Technical Details
Data Structures
These constraints make root/course-level resolution race-safe via plain
get_or_create()insidetransaction.atomic(), the pattern this repo already uses elsewhere for constraint-backedget_or_create()calls, rather thanselect_for_updateor catch-and-retry, neither of which appears in this codebase. A leaf group never setscourse_idand always has a non-nullparent_id, so it never matches either constraint's condition: no new depth/level column is needed, since(parent_id, course_id)alone identifies root, course-level, and leaf.Logic
Tagfrom the URL'stag_id; 404 if missing or not backed by aCompetencyTaxonomy.object_idviaUsageKey.from_string; 400 with a field-level error if it doesn't parse.CourseRunfrom the parsed key's course key viaopenedx_catalog.api.get_course_run(); 400 if no matching row. Thiscourse_runis the single source of truth for course: it drives both leaf-group naming and the course-scope check, whichever branch runs.oel_tagging.can_tag_object; 403 on failure. This composite (studio write access to the subsection's course plus taxonomy view access) is already registered globally byopenedx-platform'scontent_taggingapp, so no new predicate is needed.ObjectTag.objects.filter(object_id=object_id, taxonomy_id=tag.taxonomy_id, tag_id=tag.id).first(). If found, check whether anyCompetencyCriterionalready references it (CompetencyCriterion.objects.filter(oel_tagging_objecttag=existing_object_tag).exists()); if so, reject with a 400ValidationError(this repo's existing pattern for uniqueness violations — seesrc/openedx_tagging/rest_api/v1/serializers.py'svalidate_tag_value; there is no 409 usage anywhere in this codebase's REST API). If noObjectTagexists yet for this tag on this object, there is nothing to conflict with; proceed. This check runs before any group creation, regardless of whethergroup_idwas supplied, so a duplicate attempt via the derive-or-create path doesn't leave behind an orphaned empty group (ADR 0002 forbids persisting empty groups).group_id/logic_operatorexclusivity check: if bothgroup_idandlogic_operatorare supplied, reject with a 400ValidationErrornaming the conflict, before resolving or creating anything.logic_operatoronly ever sets a newly created leaf's operator; an existing leaf's operator is [BE] Build endpoint for removing a Competency Criteria Group #675's to change, not this endpoint's, so the two fields together are a client error rather than an ambiguity for this endpoint to resolve on its own.group_id: if supplied, callresolve_supplied_leaf_group(); if not, callresolve_or_create_leaf_group(), passinglogic_operatorthrough so the newly created leaf'slogic_operatorcolumn is set from it, defaulting to"OR"if omitted. Both return aCompetencyCriteriaGroupguaranteed to be a valid, course-matched leaf (see Data Structures for the supplied-group validation order).transaction.atomic()block covering steps 7-8: callcreate_competency_criterion()on the resolved/created leaf. Keeping this inside the same atomic block as group resolution matters for the no-group_idbranch: if criterion creation fails (a containment violation from [BE] Enforce competency-hierarchy dominance for Competency Criteria #666's seam, an invalid override), the rollback must also undo any newly-created root/course-level/leaf groups, since ADR 0002 forbids persisting empty groups.create_competency_criterion()'s own read-merge-write step will re-resolve the sameObjectTagrow checked in step 5 (or find none, on a first association) — an implementer may thread the already-resolvedObjectTagthrough to avoid a redundant query, but this is an optimization, not a correctness requirement.competency_criteria_group_idreferring to whichever leaf was used.Public-API impact: additive functions in
applets/cbe/api.py, reusingopenedx_tagging.api.get_object_tags/tag_objectandopenedx_catalog.api.get_course_rununchanged. No breaking changes: greenfield work, no shipped callers..importlinterneedsopenedx_catalogandopenedx_learningadded to its layering contract if #613 hasn't already, since this ticket's course-run lookup crosses intoopenedx_catalog.Test strategy: unit tests for
associate_competency_criterioncovering both branches (derive-or-create: first association, same-course reuse, different-course reuse, a concurrent-request race not producing duplicate root/course-level rows,logic_operatorsupplied vs. defaulted to"OR"; supplied-group: happy path, non-leaf rejection, ownership-mismatch rejection, course-mismatch rejection, not-found) plus the three duplicate-association rejection cases (same group, different existing group, derive-path) plus thegroup_id/logic_operatormutual-exclusion rejection plus DRF integration tests for every acceptance criterion above.Example Resolution Prompt
Implement
POST /cbe/rest_api/v1/competencies/<int:tag_id>/criteria/per the Data Structures and Logic above: request body{object_id, group_id?, logic_operator?, competency_rule_profile_id?, rule_type_override?, rule_payload_override?}. Assume #613 has landedCompetencyCriteriaGroup/CompetencyCriterionmatching ADRdocs/openedx_learning/decisions/0002-competency-criteria-model.rst; code goes undersrc/openedx_learning/applets/cbe/per ADR 0001.Build
associate_competency_criterion()as the singleapi.pyentry point, following the Technical Details signatures and call order exactly: course/tag resolution, the permission check, the duplicate-association check, thegroup_idbranch, then the shared atomic block. OneCompetencyCriterionSerializer, no variant needed, withgroup_idgenuinely optional, mirroring the existing optional-field-driven-branching pattern in this repo'sTaxonomyTagsViewtag-creation body (see Context). Reuse the existingoel_tagging.can_tag_objectpermission; no new predicate. Do not validate gradeability ofobject_id— deliberately out of scope, see Open Questions. Do not implement containment/isolation-rule validation — that is #666's scope, not this ticket's.Verify against every scenario in Acceptance Criteria above.
Context
_validate_containment(group, object_id)seam this ticket reserves (see Explicitly out of scope above for its resolved scope).CompetencyCriteriaGroup.openedx-platform(not yet numbered): this ticket'soel_tagging.can_tag_objectcheck (step 4 above) currently resolves course objects through a legacy-only role check that never consults the new authorization service, even after a course has switched over to it, unlike Studio's own tag-editing endpoint, which already handles that switch correctly. The companion ticket fixes the shared permission check itself; this ticket needs no code change once it lands, sincehas_perm('oel_tagging.can_tag_object', ...)will simply start returning the right answer.docs/openedx_learning/decisions/0002-competency-criteria-model.rst): canonical field lists and tree semantics for both models; its rule against persisting empty groups is why group creation and criterion creation share one transaction.src/openedx_learning/applets/cbe/.openedx-platform'scontent_tagging/rules.py: overridesoel_tagging.can_tag_objectwith the permission composite this ticket relies on.src/openedx_tagging/rest_api/v1/views.py'sTaxonomyTagsView: precedent for an optional request field (parent_tag_value) branching create logic downstream, the patterngroup_idfollows.src/openedx_tagging/rest_api/v1/exception_handlers.py: onlyAPIException/Http404/PermissionDeniedmap to a clean response, henceget_object_or_404over a bare.get()for the supplied-group_idpath.src/openedx_tagging/rest_api/v1/serializers.py'svalidate_tag_value: this repo's only existing uniqueness-violation precedent, a 400ValidationError, the pattern the duplicate-association check follows. No 409 usage exists anywhere in this codebase's REST API.src/openedx_tagging/models/base.py'sObjectTag:unique_togetheron(object_id, taxonomy, tag_id), which the duplicate-association check relies on.src/openedx_tagging/api.py(get_object_tags,tag_object): the two public functions this ticket's read-merge-write logic composes.openedx_catalog.api.get_course_run(): resolves theCourseRunfrom a course key; also the source of a course's display name for leaf-group naming. See Open Questions for why gradeable-subsection validation isn't checked against it.Files to create and modify
New files
src/openedx_learning/applets/cbe/migrations/0002_criteria_group_unique_constraints.pyUniqueConstraints onCompetencyCriteriaGroupsrc/openedx_learning/applets/cbe/rest_api/v1/serializers.pyCompetencyCriterionSerializer(object_idrequired;group_idgenuinely optional; optionallogic_operator, rule, and profile fields)src/openedx_learning/applets/cbe/rest_api/v1/views.pyCompetencyCriterionCreateView(generics.CreateAPIView), the single merged viewsrc/openedx_learning/applets/cbe/rest_api/v1/urls.pycompetencies/<int:tag_id>/criteria/onlysrc/openedx_learning/applets/cbe/rest_api/v1/tests/test_views.pysrc/openedx_learning/applets/cbe/tests/test_api.pyassociate_competency_criterion,resolve_or_create_leaf_group,resolve_supplied_leaf_groupModified files
src/openedx_learning/applets/cbe/api.pyresolve_or_create_leaf_group,resolve_supplied_leaf_group,create_competency_criterion,associate_competency_criterion(create the file first if #613 hasn't already)src/openedx_learning/urls.py,rest_api/urls.py,rest_api/v1/__init__.py.importlinteropenedx_catalogandopenedx_learningto the layering contract, if #613 didn't alreadycms/envs/common.py(openedx-platform)'openedx_learning'toINSTALLED_APPSlms/envs/common.py(openedx-platform)cms/urls.py(openedx-platform)path('api/cbe/rest_api/', include('openedx_learning.urls'))