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

    Projects

    Status
    Needs additional details

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions