Skip to content

feat: add bulk update competency criteria endpoint - #6

Closed
javoconsultant wants to merge 2 commits into
alezconsultant:alezconsultant/665-criterion-endpoint-on-846from
javoconsultant:javoconsultant/759-bulk-update-criteria
Closed

javoconsultant wants to merge 2 commits into
alezconsultant:alezconsultant/665-criterion-endpoint-on-846from
javoconsultant:javoconsultant/759-bulk-update-criteria

Conversation

@javoconsultant

@javoconsultant javoconsultant commented Oct 14, 2026 •

Copy link
Copy Markdown

Description

Implements openedx#759: one request that changes the rule for a batch of existing
CompetencyCriterion rows in a leaf group. An author who wants to correct a shared threshold
(say, several assignments all evaluated at "75% or higher") no longer has to edit each criterion
one at a time.

PATCH /api/cbe/v1/criteria-groups/<int:group_id>/criteria/bulk-update/

The body names the criteria, plus exactly one rule source: a rule profile to follow, or a
complete pair of rule values.

{"criterion_ids": [12, 13], "rule_type_override": "Grade", "rule_payload_override": {"op": "gte", "value": 0.75, "scale": "percent"}}
{"criterion_ids": [12, 13], "rule_profile_id": 1}

The response is 200 with one CompetencyCriterionSerializer row per named criterion, in
request order, including rows the update didn't change. All three rule columns are returned as
stored, so the response alone shows whether each criterion now follows a profile or has its own
values. Either the whole batch is applied or none of it is.

Base branch: this PR targets alezconsultant/665-criterion-endpoint-on-846, the branch
behind openedx#847 (openedx#665's create endpoint). That branch has the create endpoint,
CompetencyCriterionSerializer, and the CBE routes this PR extends. This PR adds one commit,
35917f0. Once openedx#847 merges, I'll rebase this onto main and open it
against openedx/openedx-core.

Which roles this affects: Platform Administrator and Course Author, through Studio once the
frontend work (openedx#709, openedx#710) calls it. Developer: a new
public API function and endpoint.

Changes

  • src/openedx_learning/applets/cbe/api.py:
  • src/openedx_learning/applets/cbe/rest_api/v1/serializers.py: adds
    CompetencyCriterionBulkUpdateSerializer. It checks field types only and rejects keys it
    doesn't recognize.
  • src/openedx_learning/applets/cbe/rest_api/v1/views.py: adds
    CompetencyCriterionBulkUpdateView, which accepts only PATCH. It parses the body, calls the
    API function, converts Django's ValidationError to DRF's, and converts
    CompetencyCriterionArchivedError to a 409.
  • src/openedx_learning/applets/cbe/rest_api/v1/urls.py: registers the route as
    criterion-bulk-update.
  • tests/openedx_learning/applets/cbe/test_api.py and test_views.py: tests covering the
    scenarios below.

This PR adds no migrations, models, or fields.

How it behaves

  • A criterion follows a profile or has its own values, never both. Naming a profile clears
    a criterion's override values, and supplying values clears its profile link (ADR-0002
    Decision 4). All three columns are written in one save(update_fields=[...]), so no history
    row records both, or neither.

  • Values that match the applicable profile return the criterion to that profile. If the
    submitted values exactly match the profile that currently applies to a criterion, the
    criterion is stored as following that profile, not as a redundant override. The comparison
    uses parsed dicts, so key order doesn't matter. It is decided per criterion: once scoped
    profiles exist, two criteria in the same group can resolve to different profiles. The system
    default is looked up once per batch, and only when override values are submitted, so a
    larger batch costs no extra queries for it.

  • An archived criterion refuses the whole batch. If any named criterion is archived, the
    request is refused with 409, the response names the archived ids, and no criterion in the
    batch changes. This check runs after every 400 and 404 check, as the ticket's check order
    requires: 409 means the request is well formed but the state of the batch blocks it.

  • The payload rules aren't duplicated here. The submitted rule is validated once for the
    batch by calling full_clean() on an unsaved CompetencyCriterion. The payload rules
    (allowed op values, a threshold in [0.0, 1.0], only Grade implemented) stay in the model
    layer.

  • History records only real changes. A criterion that already has the requested rule is
    skipped and gets no new history row. A changed criterion gets exactly one row, with:

    • _history_user set from the caller, so attribution doesn't depend on
      HistoryRequestMiddleware.
    • a _change_reason of "Rule values set" or "Reassigned to rule profile".

    QuerySet.update() is never used, because it skips post_save and would write no history.

  • The permission check lives in the API function, not on the view. It checks
    oel_tagging.can_tag_object against the group's taxonomy and the leaf's course. Leaf groups
    have no course_id, so the course comes from the leaf's course-level parent. An in-process
    Studio caller is refused in exactly the same cases as an HTTP caller.

  • Learner status is never read or written. Warning an author when learners already have a
    recorded status belongs to [EPIC-CC] Competency Guardrails openedx/openedx-core#723, which will sit in front of this endpoint.

Status When
200 Success, including when some or all criteria were already up to date
400 Empty or duplicate criterion_ids; both, neither, or only half of the rule forms; an unknown or archived profile; a payload the model rejects; an unrecognized body key; a group that isn't a leaf
401 Unauthenticated caller
403 Caller fails oel_tagging.can_tag_object for the group's taxonomy and course
404 The group doesn't exist, or any named id isn't a criterion in that group
405 Any method other than PATCH
409 Any named criterion is archived

Deviations from openedx#759

  • Profile resolution is a stand-in. _resolve_applicable_rule_profile() returns the system
    default, or None once the default is archived. In this phase the system default is the only
    profile that exists. It is already called once per criterion, so replacing it with [OOS - BE] Reassign existing Competency Criteria when a taxonomy-level rule profile is created openedx/openedx-core#679's
    helper won't change the code that calls it.
  • The body field is rule_profile_id, not the ticket's competency_rule_profile_id. On
    the base branch, CompetencyCriterionSerializer uses rule_profile_id in both requests and
    responses, and this endpoint matches it. The Python API keeps the ticket's
    competency_rule_profile_id keyword.
  • Shape errors come before permission errors. The serializer checks types and unknown keys
    in the view, before the API function checks permission. So a caller without permission who
    sends a malformed body gets 400, not 403. Inside the API function, only the group lookup and
    the leaf check run before the permission check, because the check needs the leaf's course.

Supporting information

Issue numbers in this section refer to openedx/openedx-core.

Testing instructions

Automated:

pytest tests/openedx_learning/applets/cbe/ --no-cov

Manual steps. Use a Studio instance with api/cbe/ mounted (the one-line cms/urls.py
companion change described in openedx#847). Log in as a user with Studio write
access to the course and view access to the competency taxonomy.

  1. Create two criteria for the same competency in the same course with
    POST /api/cbe/v1/competencies/<tag_id>/criteria/. Pass the first response's group_id in
    the second request so both criteria land in the same leaf. Note both criterion ids and the
    group_id.
  2. PATCH /api/cbe/v1/criteria-groups/<group_id>/criteria/bulk-update/ with
    {"criterion_ids": [<a>, <b>], "rule_type_override": "Grade", "rule_payload_override": {"op": "gte", "value": 0.75, "scale": "percent"}}.
    Expect 200. Both rows should have rule_profile_id: null and the submitted values.
  3. Send the same request with the seeded default's values,
    {"scale": "percent", "value": 0.8, "op": "gte"} (the key order is deliberately different).
    Expect both rows to follow the default again: rule_profile_id set and both override
    fields null.
  4. Send step 3's request again. Expect 200, and no new history rows. To check the history, run
    this in a Django shell:
    from openedx_learning.models import CompetencyCriterion
    for h in CompetencyCriterion.history.filter(id=<a>).order_by("-history_date")[:3]:
        print(h.history_change_reason, h.history_user, h.rule_profile_id, h.rule_payload_override)
    The newest two rows should be Reassigned to rule profile (step 3) and Rule values set
    (step 2), both attributed to your user.
  5. Check that each of these requests is refused and leaves both criteria unchanged:
    • rule_profile_id together with the override pair: 400 on rule_profile_id.
    • "value": 75 instead of a fraction: 400.
    • An id from another group, or 999999, added to criterion_ids: 404.
    • The course-level group's id in the URL: 400 on group_id.
    • A user without write access to the course: 403.
  6. Archive criterion <b> in a Django shell:
    from openedx_learning.models import CompetencyCriterion
    CompetencyCriterion.objects.filter(id=<b>).update(archived=True)
    Repeat step 2's request. Expect 409 naming <b>, and criterion <a> unchanged.

Verification

Run locally on 35917f0:

  • pytest tests --no-cov: 1167 passed, 1 skipped. There are also 2 errors, in
    tests/openedx_tagging/test_migrations.py; they happen the same way on main in my
    environment and aren't touched by this PR.
  • The CBE suite alone: 272 passed, 62 of them new in this PR (each parametrized case counted
    separately).
  • pylint, mypy, pycodestyle, isort --check-only: clean on every changed file.
    pydocstyle src (run the same way tox runs it) is also clean.
  • lint-imports: 3 contracts kept, 0 broken.
  • makemigrations --check --dry-run: no changes detected.

🤖 Generated with Claude Code

@alezconsultant
alezconsultant force-pushed the alezconsultant/665-criterion-endpoint-on-846 branch from dd837ca to dd5f40e Compare October 14, 2026 20:13
@javoconsultant
javoconsultant force-pushed the javoconsultant/759-bulk-update-criteria branch from 35917f0 to 33cead3 Compare October 15, 2026 14:30
@alezconsultant
alezconsultant force-pushed the alezconsultant/665-criterion-endpoint-on-846 branch 2 times, most recently from 6034b36 to c91b6fa Compare October 15, 2026 18:21
alezconsultant and others added 2 commits October 16, 2026 11:27
Add POST /api/cbe/v1/competencies/<int:tag_id>/criteria/, associating a gradeable
subsection with a competency. When group_id is supplied, the CompetencyCriterion is
attached to that existing leaf CompetencyCriteriaGroup. When it is omitted, the root and
course-level groups for this competency and course are reused or created, and a new leaf
is always created under them.

Add to applets/cbe/api.py, all marked UNSTABLE: resolve_competency_tag(),
resolve_supplied_leaf_group(), create_competency_criterion(), and
associate_competency_criterion() as the single entry point the view calls. Group creation
is the private _create_leaf_group(), so groups are only ever created behind the
duplicate-association check. The API raises Django ValidationError and model DoesNotExist
exceptions; the view translates them into 400 and 404 responses, and a 404 carries the
exception's message, so a missing tag, a tag outside a competency taxonomy, and a missing
group can be told apart.

Group creation and the duplicate-association check run inside one transaction that first
locks the competency's Tag row with select_for_update(), so concurrent requests for the
same competency are serialized. MySQL has no partial unique indexes, so the one-root-per-tag
rule cannot be backed by a constraint and relies on that lock. Add migration 0015: a
UniqueConstraint on CompetencyCriteriaGroup (tag, course, parent) that backstops
course-level group creation. The root and course-level lookups match archived rows
deliberately, so an archived container is never replaced by a second row in its slot.

When a request supplies no rule fields, the criterion is assigned the live rule profile
scoped to the competency's taxonomy if one exists, and the seeded system-default profile
otherwise. It is never left with all three rule fields null, because CompetencyCriterion's
profile_xor_override_check constraint forbids that. A request that supplies a profile
together with an override, only one of the two overrides, or an archived or unknown profile
is rejected with a 400 before anything is written.

Related to openedx#665

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants