Skip to content

[BE] Build endpoint for creating Competency Criteria #665

Description

@thelmick-unicon

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-platform INSTALLED_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.
  • [BE] Implement CBE core data models (CompetencyTaxonomy, criteria, learner status) #613 (5.1): the model/migration this ticket depends on for implementation.
  • 5.6b: the split-out companion ticket for the three deferred business rules.
  • 5.4: the frontend course-search UI, the likely eventual caller of this endpoint — not designed here.
  • 5.7: GET endpoint for groups + criteria, separate ticket.

Technical Notes

Files to Create

Conditional — check first whether 5.5 already created these:

File Purpose
src/openedx_learning/applets/cbe/rest_api/v1/tests/test_views.py (extend if 5.5 created it) view/permission/response-shape tests for criteria creation

Files to Modify

File Nature of modification
src/openedx_learning/applets/cbe/api.py add create_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.py add CompetencyCriterionSerializer
src/openedx_learning/applets/cbe/rest_api/v1/views.py add CompetencyCriterionCreateView(generics.CreateAPIView)
src/openedx_learning/applets/cbe/rest_api/v1/urls.py register criteria-groups/<int:group_id>/criteria/

Implementation Notes

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:

  1. Resolve CompetencyCriteriaGroup from group_id; 404 if missing.
  2. Parse object_id via opaque_keys.edx.keys.UsageKey.from_string; 400 with a field-level error if it doesn't parse.
  3. If the group's course_id is set, validate the parsed subsection’s parent course (parsed_key.course_key) matches it; 400 on mismatch.
  4. 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.
  5. Create the CompetencyCriterion row: competency_criteria_group_id=group.id, oel_tagging_objecttag_id=<resolved>, plus any optional profile/override fields.
  6. 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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. src/openedx_learning/applets/cbe/rest_api/v1/urls.py: register path("criteria-groups/<int:group_id>/criteria/", views.CompetencyCriterionCreateView.as_view(), name="competency-criterion-create").

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    Status
    Ready for Community Review

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions