Skip to content

feat: add bulk update competency criteria endpoint - #874

Merged
ormsbee merged 1 commit into
openedx:mainfrom
javoconsultant:javoconsultant/759-bulk-update-criteria
Oct 22, 2026
Merged

ormsbee merged 1 commit into
openedx:mainfrom
javoconsultant:javoconsultant/759-bulk-update-criteria

Conversation

@javoconsultant

@javoconsultant javoconsultant commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Description

Implements #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: main. This was first opened as alezconsultant#6, stacked on
#847 (#665's create endpoint), which has the create endpoint,
CompetencyCriterionSerializer, and the CBE routes this PR extends. #847 is now in main, and
this PR targets main directly.

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

Changes

  • src/openedx_learning/applets/cbe/api.py:
    • adds bulk_update_competency_criteria() and CompetencyCriterionArchivedError. Both are
      public, marked UNSTABLE like the other criteria-authoring functions, and added to
      __all__.
    • moves the applicable-profile lookup out of create_competency_criterion() into a private
      _get_applicable_rule_profile(taxonomy_id), which both the create path and the bulk update
      call. It returns the live profile scoped to the taxonomy, else the live system default, else
      None. create_competency_criterion() still raises CompetencyRuleProfile.DoesNotExist
      when neither profile exists.
    • adds two private helpers:
      • _validate_rule_source() validates the batch's rule source once, not once per criterion.
        It calls the create path's _validate_rule_fields() first, so both endpoints refuse the
        same mistakes (both rule forms, half a pair, an unknown or archived profile) with the same
        messages under the same keys. The only checks it adds are the refusal of a request with no
        rule source at all and the full_clean() of the override values.
      • _apply_rule_source() writes one criterion's three rule columns in a single save, and
        skips the save when nothing would change.
  • 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, and translates the function's exceptions: CompetencyCriteriaGroup.DoesNotExist
    and CompetencyCriterion.DoesNotExist become 404, Django's ValidationError becomes DRF's,
    and CompetencyCriterionArchivedError becomes 409. api.py itself raises no Http404,
    because it is also called from code that isn't a view.
  • 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 applies to a criterion, the criterion is
    stored as following that profile, not as a redundant override (ADR-0002 Decision 4's fourth
    write event). The applicable profile is the one create_competency_criterion() assigns: the
    live profile scoped to the group's competency taxonomy, else the live system default. So a
    bulk update never moves a criterion off the profile the create endpoint gave it. The
    comparison uses parsed dicts, so key order doesn't matter. Every criterion in a leaf shares
    the group's tag, and therefore its taxonomy, so the profile is looked up once per batch, and
    only when override values are submitted.

  • An archived criterion or an archived group refuses the whole batch. The request is
    refused with 409, and no criterion in the batch changes, if any named criterion is archived,
    or if the leaf group, its course-level parent, or the root group is archived. The response's
    detail names the archived criteria or the archived group. Archiving a group doesn't archive
    the criteria under it, so groups need their own check: ADR-0002 Decision 2 hides archived
    groups from authoring, and an archived ancestor stands in for a delete that would have
    cascaded down to the leaf.

  • The checks run in a fixed order. The view's serializer first checks field types and
    unknown keys (400). The API function then runs these checks, in this order:

    1. The group must exist (404) and be a leaf (400).
    2. The caller must pass the permission check (403).
    3. criterion_ids must be non-empty with no duplicates, and the rule source must be valid
      (400).
    4. No group in the leaf's lineage may be archived (409).
    5. Every named id must be a criterion in the group (404).
    6. No named criterion may be archived (409).

    A 409 means the request is well formed but the state of the batch blocks it. Steps 4 and 5
    lock the lineage groups and the named criteria with select_for_update(). That way a
    concurrent archive can't land between the archived checks and the saves, and a concurrent
    identical request waits, then finds nothing to change and writes no second history row.

  • 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. The model reports a bad payload as a non-field error, and the API function re-keys it
    under rule_payload_override. When rule_type_override is itself refused, only that error is
    returned, because a payload is only judged against a valid rule type.

  • 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 #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, keyed by rule_payload_override; 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. The detail names the ids that didn't resolve.
405 Any method other than PATCH
409 Any named criterion is archived, or the leaf group, its course-level parent, or the root group is archived. The detail names the archived criteria or the archived group.

Deviations from #759

  • The applicable profile is resolved once per batch, from the group's taxonomy. [BE] Update/Edit Competency Criteria #759 asks
    for a call to [OOS - BE] Reassign existing Competency Criteria when a taxonomy-level rule profile is created #679's shared resolution helper for each criterion. [OOS - BE] Reassign existing Competency Criteria when a taxonomy-level rule profile is created #679 is out of scope for
    this phase, so this PR reuses the lookup create_competency_criterion() already does, moved
    into _get_applicable_rule_profile(). That lookup takes a taxonomy id, and every criterion in
    a leaf has the group's tag, so one call covers the whole batch. Course- and
    organization-scoped profiles are not resolved, the same as on the create path.
  • Field and keyword names follow Alezconsultant/665 criterion endpoint on 846 #847, not the ticket. The body field and the Python
    keyword are both rule_profile_id, and the Python API's group argument is group_id. The
    ticket has competency_rule_profile_id and competency_criteria_group_id. rule_profile_id
    is the name associate_competency_criterion(), create_competency_criterion(), the model
    field, and CompetencyCriterionSerializer already use.
  • An archived group refuses the batch. [BE] Update/Edit Competency Criteria #759 only refuses archived criteria. Because
    archiving a group doesn't archive its criteria, this PR also refuses a batch under an archived
    leaf, course-level, or root group, with the same 409. The ticket puts every 404 before any
    409, but the archived-group check runs before the criterion lookup. So a batch addressed to
    an archived group gets 409 even if it also names an id outside the group.
  • 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 #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 values of the profile that applies to the competency's
    taxonomy. On an instance with only the seeded system default, those values are
    {"scale": "percent", "value": 0.8, "op": "gte"} (the key order is deliberately different).
    If the taxonomy has its own live profile, use that profile's values instead. Expect both rows
    to follow that profile again: rule_profile_id set to the profile step 1 assigned, 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 on rule_payload_override.
    • "rule_type_override": "Completion": 400 on rule_type_override only.
    • An id from another group, or 999999, added to criterion_ids: 404, with the detail
      naming that id.
    • 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.
  7. Unarchive <b>, then archive the leaf group, in a Django shell:
    from openedx_learning.models import CompetencyCriteriaGroup, CompetencyCriterion
    CompetencyCriterion.objects.filter(id=<b>).update(archived=False)
    CompetencyCriteriaGroup.objects.filter(id=<group_id>).update(archived=True)
    Repeat step 2's request. Expect 409 naming group <group_id>, and both criteria unchanged,
    even though neither criterion is archived.

Verification

Run locally on 66dc797:

  • pytest tests --no-cov: 1227 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, tests/openedx_learning/applets/cbe/: 332 tests, all passing, 71 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

@openedx-webhooks openedx-webhooks added the open-source-contribution PR author is not from Axim or 2U label Oct 16, 2026
@openedx-webhooks

openedx-webhooks commented Oct 16, 2026 •

Copy link
Copy Markdown

Thanks for the pull request, @javoconsultant!

This repository is currently maintained by @axim-engineering.

Once you've gone through the following steps feel free to tag them in a comment and let them know that your changes are ready for engineering review.

🔘 Get product approval

If you haven't already, check this list to see if your contribution needs to go through the product review process.

  • If it does, you'll need to submit a product proposal for your contribution, and have it reviewed by the Product Working Group.
    • This process (including the steps you'll need to take) is documented here.
  • If it doesn't, simply proceed with the next step.
🔘 Provide context

To help your reviewers and other members of the community understand the purpose and larger context of your changes, feel free to add as much of the following information to the PR description as you can:

  • Dependencies

    This PR must be merged before / after / at the same time as ...

  • Blockers

    This PR is waiting for OEP-1234 to be accepted.

  • Timeline information

    This PR must be merged by XX date because ...

  • Partner information

    This is for a course on edx.org.

  • Supporting documentation
  • Relevant Open edX discussion forum threads
🔘 Get a green build

If one or more checks are failing, continue working on your changes until this is no longer the case and your build turns green.

Details
Where can I find more information?

If you'd like to get more details on all aspects of the review process for open source pull requests (OSPRs), check out the following resources:

When can I expect my changes to be merged?

Our goal is to get community contributions seen and reviewed as efficiently as possible.

However, the amount of time that it takes to review and merge a PR can vary significantly based on factors such as:

  • The size and impact of the changes that it introduces
  • The need for product review
  • Maintenance status of the parent repository

💡 As a result it may take up to several weeks or months to complete a review and merge your PR.

@github-project-automation github-project-automation Bot moved this to Needs Triage in Contributions Oct 16, 2026
@javoconsultant javoconsultant self-assigned this Oct 16, 2026
@javoconsultant
javoconsultant marked this pull request as ready for review October 16, 2026 17:01
@javoconsultant
javoconsultant force-pushed the javoconsultant/759-bulk-update-criteria branch from d324a1c to 193898c Compare October 16, 2026 17:36

@ormsbee ormsbee left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude ran the CBE suite (283 tests), the quality checks, and makemigrations --check on d324a1c for me, all clean, and then sent a few requests the tests don't send; that's where the first item below comes from.

The shape is right and the tests are thorough for what they cover. Three things I'd like fixed before merge, two of which I suspect just predate the last round on #847, since this branch was stacked on it:

  1. The "values match the applicable profile" check compares against the system default even when a live taxonomy-scoped profile applies, which is the opposite of what create_competency_criterion() has done since #847, so a bulk update can move a criterion off the profile the create endpoint gave it (inline on api.py). Please use the same lookup in both places.
  2. Please keep Http404 out of api.py, same as on #847 (inline).
  3. Please call the keyword rule_profile_id, to match associate_competency_criterion() (inline).
  4. Please make _validate_rule_source() to build on _validate_rule_fields() instead of restating it.

The rest of the inline comments are nits and questions.

Thank you.


Claude drafted this review and ran the test suite, quality checks, makemigrations --check, and the probes quoted inline on d324a1c for me.

Comment thread src/openedx_learning/applets/cbe/api.py Outdated
Comment thread src/openedx_learning/applets/cbe/api.py Outdated
Comment thread src/openedx_learning/applets/cbe/api.py Outdated
Comment thread src/openedx_learning/applets/cbe/api.py
Comment thread src/openedx_learning/applets/cbe/api.py Outdated
Comment thread src/openedx_learning/applets/cbe/api.py
Comment thread src/openedx_learning/applets/cbe/api.py
Comment thread src/openedx_learning/applets/cbe/api.py
Comment thread tests/openedx_learning/applets/cbe/test_api.py Outdated
Comment thread tests/openedx_learning/applets/cbe/test_api.py Outdated
@tbain

tbain commented Oct 19, 2026

Copy link
Copy Markdown
Contributor

For documentation purposes, this was Unicon reviewed and approved in a prior PR here: ufedaseyeuconsultant#2

@javoconsultant javoconsultant left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ormsbee i've addressed all your comments

@ormsbee ormsbee left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the quick turnaround on all of this. Claude re-ran the CBE suite (332 tests), the quality checks, and makemigrations --check on 66dc797 for me, all clean, and CI is green on MySQL. The taxonomy-profile probe from last round now comes out the way the ADR says (values equal to the taxonomy profile's return the criterion to it, values equal to the system default's are stored as an override), and the archived-group gap was a real one, so thank you for chasing it down.

Everything from the last round is addressed. I only have an optional nit. Please squash to one commit with the PR description's context in the commit body, and I'll merge.

Thank you.


Claude drafted this review and re-ran the test suite, quality checks, makemigrations --check, and the probes on 66dc797 for me.


profile_reads = [q for q in queries.captured_queries if q["sql"].startswith("SELECT") and profile_table in q["sql"]]
# One lookup: the taxonomy's own profile, then the system default, since the taxonomy has none.
assert len(profile_reads) == 2

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit (optional): the test's name says once and the assertion says two. _get_applicable_rule_profile() could be one query, something like live.filter(Q(competency_taxonomy_id=taxonomy_id) | Q(competency_taxonomy__isnull=True)).order_by(F("competency_taxonomy_id").desc(nulls_last=True)).first(), which would make both true and save a query on create as well. I haven't tried it, and it's not worth holding anything for.

Honestly, I feel like the entire notion of capturing queries and testing for "selects" are not really a useful thing. Despite the specific wording, the most important part is just making sure we're not doing excessive querying. But again, it's just a nit.

@javoconsultant javoconsultant Oct 22, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addressed in the last commit

Comment thread src/openedx_learning/applets/cbe/api.py
@javoconsultant
javoconsultant force-pushed the javoconsultant/759-bulk-update-criteria branch from c50e6b4 to eb0a019 Compare October 22, 2026 15:03
@mphilbrick211 mphilbrick211 moved this from Needs Triage to In Eng Review in Contributions Oct 22, 2026
@ormsbee
ormsbee merged commit c9d494e into openedx:main Oct 22, 2026
7 checks passed
@github-project-automation github-project-automation Bot moved this from In Eng Review to Done in Contributions Oct 22, 2026
@javoconsultant
javoconsultant deleted the javoconsultant/759-bulk-update-criteria branch October 23, 2026 09:24
ormsbee pushed a commit to openedx/openedx-platform that referenced this pull request Oct 23, 2026
Regenerated via `make upgrade-package package=openedx-core`. Picks up
openedx/openedx-core#874 (bulk update of competency criteria, first
released in 1.15.0) so the Competency-Based Education (CBE) work can
go to QA.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

open-source-contribution PR author is not from Axim or 2U

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

5 participants