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 (5.1, the CBE data model) and 5.5 (a criterion attaches into a group that 5.5's endpoint creates; 5.5 also establishes the openedx-platform wiring this ticket's new route inherits). Confirmed against the openedx-core checkout: src/openedx_learning/ does not exist yet; there is no code for CompetencyCriterion anywhere in the repository. It exists only as approved design in ADR 0002 (docs/openedx_learning/decisions/0002-competency-criteria-model.rst:130-149).
Scope Split: This ticket covers core creation and baseline referential-integrity checks only. The three richer rules are tracked as a companion ticket, 5.6b.
Repo: openedx-core, single-repo.
Use Case
As a Platform Administrator, I want to create a Competency Criterion that associates a competency with a gradable subsection (aka assignment) in a course, stored under the correct Competency Criteria Group, so that this competency has a concrete rule the (future) evaluation pipeline can use to determine learner mastery.
Description
Current state
No endpoint exists today to create a CompetencyCriterion. Per ADR 0002, the model is a leaf node in a competency's criteria tree, stored in table CompetencyCriteria, with columns id, competency_criteria_group_id (FK to CompetencyCriteriaGroup), oel_tagging_objecttag_id (the tag/object association — this is how the criterion points at gradable content; there is no direct course or subsection FK), competency_rule_profile_id (nullable FK to CompetencyRuleProfile), rule_type_override, and rule_payload_override (both nullable, overriding the profile when set).
Requested change
A new endpoint, nested under an existing CompetencyCriteriaGroup, that creates a CompetencyCriterion:
Resolves the target group from the URL and validates it belongs to the competency being associated (mirroring 5.5's parent-mismatch check).
If the group is course-scoped (course_id set), validates the submitted course association matches that course.
Accepts object_id (required) — a subsection-usage-key string identifying the gradable content, matching ObjectTag.object_id's existing opaque-string convention.Resolves-or-creates the underlying oel_tagging_objecttag association via a read-merge-write pattern (fetch the object's existing tags in that taxonomy, union in the new competency tag, call the existing tag_object() with the merged set) — never a raw replace, since tag_object()'s full-replace semantics would otherwise silently wipe out any other competency tag already on that object in the same taxonomy.
Accepts optional competency_rule_profile_id, rule_type_override, rule_payload_override. It must accept either a competency_rule_profile_id or a rule_type_override and rule_payload_override.
Reuses the can_change_taxonomy permission gating, scoped to the taxonomy owning the group's competency tag.
Explicitly out of scope
Creating, updating, or deleting CompetencyCriteriaGroup rows (5.5 and 5.9).
Listing or retrieving criteria/groups (5.7).
Any UI (5.4/5.10).
The three containment/isolation business rules — no duplicate competency along the same containment path; parent competency dominates scope over its children in a course branch; groups and courses as isolation boundaries. Tracked in the 5.6b stub. Low risk in the interim: no evaluation engine consumes this data yet, so a badly-shaped tree has no immediate blast radius.
Course-level association — this ticket accepts a subsection-level object_id only.
Acceptance Criteria
These scenarios are verifiable via Postman.
Scenario: Create a Competency Criterion for a gradeable subsection association
Given a valid Competency Criteria Group exists for a competency
And the requesting user has can_change_taxonomy permission on that competency's taxonomy
When a POST request is sent to the create-criteria endpoint with a valid group id and a valid gradeable subsection object_id
Then the response returns status code 201
And the response body includes the new criterion's "id", "competency_criteria_group_id", "oel_tagging_objecttag_id", and any supplied "rule_type_override" / "rule_payload_override"
Scenario: Reusing an object 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 gradeable subsection with competency B
Then the response returns status code 201
And the gradeable subsection's object tags still include both competency A and competency B afterward
Scenario: Reject a group that does not belong to the specified competency
Given a Competency Criteria Group exists for competency A
When a POST request references competency B's context with that group's id
Then the response returns status code 400
And the response body identifies the group/competency mismatch
Scenario: Reject a gradeable subsection association that does not match the group's course scope
Given a Competency Criteria Group is scoped to Course X
When a POST request attempts to associate content from Course Y with that group
Then the response returns status code 400
And the response body identifies the course mismatch
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 gradeable subsection 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 for a group that does not exist
Given the referenced group id does not exist
When a POST request is sent referencing that id
Then the response returns status code 404
Scenario: Reject creation without permission
Given the requesting user does not have can_change_taxonomy permission on the referenced competency's taxonomy
When a POST request is sent to the create-criteria endpoint
Then the response returns status code 403
Context
5.5: creates the CompetencyCriteriaGroup this ticket's criteria attach to; the can_change_taxonomy permission precedent and CreateAPIView + public-api.py-function pattern this ticket mirrors. 5.5 also adds the openedx-platformINSTALLED_APPS/cms/urls.py wiring that makes openedx_learning's REST API reachable from Studio at all — this ticket's new route registers inside the same rest_api/v1/urls.py 5.5 already wires in, so no additional openedx-platform file changes are needed here.
ADR 0002 (docs/openedx_learning/decisions/0002-competency-criteria-model.rst:130-149, 228-263): canonical CompetencyCriterion field list; its worked example shows one object_id reused across multiple CompetencyCriterion rows without duplicating the tagging row — the reason this ticket uses read-merge-write rather than a raw tag-object replace.
src/openedx_tagging/models/base.py:757-834 (ObjectTag): plain opaque object_id string field, unique_together on (object_id, taxonomy, tag) — no object-type discriminator or resolver exists; format validation is this ticket's job (via opaque_keys).
src/openedx_tagging/api.py:176-214 (get_object_tags), :325-429 (tag_object, full-replace semantics per its own docstring) — the two public functions this ticket's read-merge-write logic composes.
Build CompetencyCriterionCreateView(generics.CreateAPIView) at POST /cbe/rest_api/v1/criteria-groups/<int:group_id>/criteria/, mirroring 5.5's CreateAPIView-only, public-api.py-function pattern (perform_create calls create_competency_criterion(), never serializer.save() directly).
Request body:object_id (required string — a subsection UsageKey string
), plus optional competency_rule_profile_id, rule_type_override, rule_payload_override. The competency and taxonomy are never in the request body — both are derived from the group resolved via the URL's group_id.
Core creation steps:
Resolve CompetencyCriteriaGroup from group_id; 404 if missing.
Parse object_id via opaque_keys.edx.keys.UsageKey.from_string; 400 with a field-level error if it doesn't parse.
If the group's course_id is set, validate the parsed subsection’s parent course (parsed_key.course_key) matches it; 400 on mismatch.
Resolve-or-create the ObjectTag via read-merge-write: call get_object_tags(object_id, taxonomy_id=group.oel_tagging_tag.taxonomy_id), union the group's competency tag's value into the existing tag values for that object+taxonomy, call tag_object(object_id, taxonomy, tags=merged_values), then re-fetch the specific ObjectTag row for (object_id, taxonomy, tag) to get its id. Never call tag_object with only the new tag's value in isolation — that would replace, not add to, the object's existing tags in that taxonomy.
Create the CompetencyCriterion row: competency_criteria_group_id=group.id, oel_tagging_objecttag_id=<resolved>, plus any optional profile/override fields.
Return 201 with the created row's representation.
Permission class: reuse TaxonomyObjectPermissions/can_change_taxonomy, scoped to the taxonomy owning the group's competency tag — same precedent 5.5 established.
Explicitly not built here (tracked in 5.6b): any check that walks the criteria-group tree for containment-path duplicates, parent/child scope dominance, or cross-group/cross-course isolation. Structure create_competency_criterion() so a future _validate_containment(group, object_id) call can be inserted before step 5 without reshaping this function's signature.
Test strategy: unit tests for create_competency_criterion() (subsection-level object_id happy path, reuse of an existing ObjectTag across two groups — asserting both tags survive, rejection of an object_id that doesn’t parse as a UsageKey, group/competency mismatch, group/course mismatch) plus DRF integration tests (201/400/403/404).
Example Resolution Prompt
Implement ticket 5.6 (core creation only; containment validation is tracked separately in 5.6b). Assume 5.5 has landed src/openedx_learning/applets/cbe/api.py with create_competency_criteria_group(), the REST scaffolding (rest_api/v1/urls.py, views.py, serializers.py), and the openedx-platform wiring (INSTALLED_APPS, cms/urls.py include) — this ticket needs no further openedx-platform changes, only new code inside openedx-core. Assume #613/5.1 has
landed CompetencyCriteriaGroup and CompetencyCriterion models matching ADR docs/openedx_learning/decisions/0002-competency-criteria-model.rst:130-149.
Build:
src/openedx_learning/applets/cbe/api.py: add create_competency_criterion(group_id: int, object_id: str, competency_rule_profile_id: int | None = None, rule_type_override: str | None = None, rule_payload_override: dict | None = None) -> CompetencyCriterion. Resolve the CompetencyCriteriaGroup (raise on missing). Parse object_id via opaque_keys.edx.keys.UsageKey.from_string (raise ValueError if it doesn't parse). If group.course_id is set, validate the parsed subsection's parent course (parsed_key.course_key) matches it.
Resolve-or-create the ObjectTag using the read-merge-write pattern described above — call openedx_tagging.api.get_object_tags then openedx_tagging.api.tag_object with the merged tag set, never the new tag alone. Create and return the CompetencyCriterion row.
src/openedx_learning/applets/cbe/rest_api/v1/serializers.py: CompetencyCriterionSerializer(serializers.ModelSerializer) with a writable object_id field (serializer-only, not a model field on CompetencyCriterion) plus the optional override/profile fields. Do not expose competency_criteria_group_id or oel_tagging_objecttag_id as writable.
src/openedx_learning/applets/cbe/rest_api/v1/views.py: CompetencyCriterionCreateView(generics.CreateAPIView). In perform_create, resolve group_id from the URL kwarg and call api.create_competency_criterion(group_id, **serializer.validated_data). Reuse TaxonomyObjectPermissions, scoped to the taxonomy owning the group's competency tag.
Return 201 on success; 400 if object_id doesn't parse as a subsection `UsageKey` or its parent course doesn't match the group's scope; 404 if the group doesn't exist; 403 if the caller lacks can_change_taxonomy. Do not implement containment/isolation-rule validation — that is 5.6b's scope, not this ticket's.
Blocked by: #613 (5.1, the CBE data model) and 5.5 (a criterion attaches into a group that 5.5's endpoint creates; 5.5 also establishes the
openedx-platformwiring this ticket's new route inherits). Confirmed against theopenedx-corecheckout:src/openedx_learning/does not exist yet; there is no code forCompetencyCriterionanywhere in the repository. It exists only as approved design in ADR 0002 (docs/openedx_learning/decisions/0002-competency-criteria-model.rst:130-149).Scope Split: This ticket covers core creation and baseline referential-integrity checks only. The three richer rules are tracked as a companion ticket, 5.6b.
Repo:
openedx-core, single-repo.Use Case
As a Platform Administrator, I want to create a Competency Criterion that associates a competency with a gradable subsection (aka assignment) in a course, stored under the correct Competency Criteria Group, so that this competency has a concrete rule the (future) evaluation pipeline can use to determine learner mastery.
Description
Current state
No endpoint exists today to create a
CompetencyCriterion. Per ADR 0002, the model is a leaf node in a competency's criteria tree, stored in tableCompetencyCriteria, with columnsid,competency_criteria_group_id(FK toCompetencyCriteriaGroup),oel_tagging_objecttag_id(the tag/object association — this is how the criterion points at gradable content; there is no direct course or subsection FK),competency_rule_profile_id(nullable FK toCompetencyRuleProfile),rule_type_override, andrule_payload_override(both nullable, overriding the profile when set).Requested change
A new endpoint, nested under an existing
CompetencyCriteriaGroup, that creates aCompetencyCriterion:course_idset), validates the submitted course association matches that course.object_id(required) — a subsection-usage-key string identifying the gradable content, matchingObjectTag.object_id's existing opaque-string convention.Resolves-or-creates the underlyingoel_tagging_objecttagassociation via a read-merge-write pattern (fetch the object's existing tags in that taxonomy, union in the new competency tag, call the existingtag_object()with the merged set) — never a raw replace, sincetag_object()'s full-replace semantics would otherwise silently wipe out any other competency tag already on that object in the same taxonomy.competency_rule_profile_id,rule_type_override,rule_payload_override. It must accept either acompetency_rule_profile_idor arule_type_overrideandrule_payload_override.can_change_taxonomypermission gating, scoped to the taxonomy owning the group's competency tag.Explicitly out of scope
CompetencyCriteriaGrouprows (5.5 and 5.9).object_idonly.Acceptance Criteria
These scenarios are verifiable via Postman.
Context
CompetencyCriteriaGroupthis ticket's criteria attach to; thecan_change_taxonomypermission precedent andCreateAPIView+ public-api.py-function pattern this ticket mirrors. 5.5 also adds theopenedx-platformINSTALLED_APPS/cms/urls.pywiring that makesopenedx_learning's REST API reachable from Studio at all — this ticket's new route registers inside the samerest_api/v1/urls.py5.5 already wires in, so no additionalopenedx-platformfile changes are needed here.docs/openedx_learning/decisions/0002-competency-criteria-model.rst:130-149, 228-263): canonicalCompetencyCriterionfield list; its worked example shows oneobject_idreused across multipleCompetencyCriterionrows without duplicating the tagging row — the reason this ticket uses read-merge-write rather than a raw tag-object replace.src/openedx_tagging/models/base.py:757-834(ObjectTag): plain opaqueobject_idstring field,unique_togetheron(object_id, taxonomy, tag)— no object-type discriminator or resolver exists; format validation is this ticket's job (viaopaque_keys).src/openedx_tagging/api.py:176-214(get_object_tags),:325-429(tag_object, full-replace semantics per its own docstring) — the two public functions this ticket's read-merge-write logic composes.Technical Notes
Files to Create
Conditional — check first whether 5.5 already created these:
src/openedx_learning/applets/cbe/rest_api/v1/tests/test_views.py(extend if 5.5 created it)Files to Modify
src/openedx_learning/applets/cbe/api.pycreate_competency_criterion(group_id, object_id, competency_rule_profile_id=None, rule_type_override=None, rule_payload_override=None)src/openedx_learning/applets/cbe/rest_api/v1/serializers.pyCompetencyCriterionSerializersrc/openedx_learning/applets/cbe/rest_api/v1/views.pyCompetencyCriterionCreateView(generics.CreateAPIView)src/openedx_learning/applets/cbe/rest_api/v1/urls.pycriteria-groups/<int:group_id>/criteria/Implementation Notes
Build
CompetencyCriterionCreateView(generics.CreateAPIView)atPOST /cbe/rest_api/v1/criteria-groups/<int:group_id>/criteria/, mirroring 5.5'sCreateAPIView-only, public-api.py-function pattern (perform_createcallscreate_competency_criterion(), neverserializer.save()directly).Request body:
object_id(required string — a subsectionUsageKeystring), plus optional
competency_rule_profile_id,rule_type_override,rule_payload_override. The competency and taxonomy are never in the request body — both are derived from the group resolved via the URL'sgroup_id.Core creation steps:
CompetencyCriteriaGroupfromgroup_id; 404 if missing.object_idviaopaque_keys.edx.keys.UsageKey.from_string; 400 with a field-level error if it doesn't parse.course_idis set, validate the parsed subsection’s parent course (parsed_key.course_key) matches it; 400 on mismatch.ObjectTagvia read-merge-write: callget_object_tags(object_id, taxonomy_id=group.oel_tagging_tag.taxonomy_id), union the group's competency tag's value into the existing tag values for that object+taxonomy, calltag_object(object_id, taxonomy, tags=merged_values), then re-fetch the specificObjectTagrow for(object_id, taxonomy, tag)to get its id. Never calltag_objectwith only the new tag's value in isolation — that would replace, not add to, the object's existing tags in that taxonomy.CompetencyCriterionrow:competency_criteria_group_id=group.id,oel_tagging_objecttag_id=<resolved>, plus any optional profile/override fields.Permission class: reuse
TaxonomyObjectPermissions/can_change_taxonomy, scoped to the taxonomy owning the group's competency tag — same precedent 5.5 established.Explicitly not built here (tracked in 5.6b): any check that walks the criteria-group tree for containment-path duplicates, parent/child scope dominance, or cross-group/cross-course isolation. Structure
create_competency_criterion()so a future_validate_containment(group, object_id)call can be inserted before step 5 without reshaping this function's signature.Test strategy: unit tests for
create_competency_criterion()(subsection-levelobject_idhappy path, reuse of an existingObjectTagacross two groups — asserting both tags survive, rejection of anobject_idthat doesn’t parse as aUsageKey, group/competency mismatch, group/course mismatch) plus DRF integration tests (201/400/403/404).Example Resolution Prompt
Implement ticket 5.6 (core creation only; containment validation is tracked separately in 5.6b). Assume 5.5 has landed
src/openedx_learning/applets/cbe/api.pywithcreate_competency_criteria_group(), the REST scaffolding (rest_api/v1/urls.py,views.py,serializers.py), and theopenedx-platformwiring (INSTALLED_APPS,cms/urls.pyinclude) — this ticket needs no furtheropenedx-platformchanges, only new code insideopenedx-core. Assume #613/5.1 haslanded
CompetencyCriteriaGroupandCompetencyCriterionmodels matching ADRdocs/openedx_learning/decisions/0002-competency-criteria-model.rst:130-149.Build:
src/openedx_learning/applets/cbe/api.py: addcreate_competency_criterion(group_id: int, object_id: str, competency_rule_profile_id: int | None = None, rule_type_override: str | None = None, rule_payload_override: dict | None = None) -> CompetencyCriterion. Resolve theCompetencyCriteriaGroup(raise on missing). Parseobject_idviaopaque_keys.edx.keys.UsageKey.from_string(raiseValueErrorif it doesn't parse). Ifgroup.course_idis set, validate the parsed subsection's parent course (parsed_key.course_key) matches it.ObjectTagusing the read-merge-write pattern described above — callopenedx_tagging.api.get_object_tagsthenopenedx_tagging.api.tag_objectwith the merged tag set, never the new tag alone. Create and return theCompetencyCriterionrow.src/openedx_learning/applets/cbe/rest_api/v1/serializers.py:CompetencyCriterionSerializer(serializers.ModelSerializer)with a writableobject_idfield (serializer-only, not a model field onCompetencyCriterion) plus the optional override/profile fields. Do not exposecompetency_criteria_group_idoroel_tagging_objecttag_idas writable.src/openedx_learning/applets/cbe/rest_api/v1/views.py:CompetencyCriterionCreateView(generics.CreateAPIView). Inperform_create, resolvegroup_idfrom the URL kwarg and callapi.create_competency_criterion(group_id, **serializer.validated_data). ReuseTaxonomyObjectPermissions, scoped to the taxonomy owning the group's competency tag.src/openedx_learning/applets/cbe/rest_api/v1/urls.py: registerpath("criteria-groups/<int:group_id>/criteria/", views.CompetencyCriterionCreateView.as_view(), name="competency-criterion-create").Return 201 on success; 400 if
object_iddoesn't parse as a subsection `UsageKey` or its parent course doesn't match the group's scope; 404 if the group doesn't exist; 403 if the caller lackscan_change_taxonomy. Do not implement containment/isolation-rule validation — that is 5.6b's scope, not this ticket's.