Repository navigation
feat: add bulk update competency criteria endpoint - #6
Closed
javoconsultant wants to merge 2 commits into
Conversation
alezconsultant
force-pushed
the
alezconsultant/665-criterion-endpoint-on-846
branch
from
October 14, 2026 20:13
dd837ca to
dd5f40e
Compare
javoconsultant
force-pushed
the
javoconsultant/759-bulk-update-criteria
branch
from
October 15, 2026 14:30
35917f0 to
33cead3
Compare
alezconsultant
force-pushed
the
alezconsultant/665-criterion-endpoint-on-846
branch
2 times, most recently
from
October 15, 2026 18:21
6034b36 to
c91b6fa
Compare
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>
javoconsultant
force-pushed
the
javoconsultant/759-bulk-update-criteria
branch
from
October 16, 2026 16:57
33cead3 to
d324a1c
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Implements openedx#759: one request that changes the rule for a batch of existing
CompetencyCriterionrows 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.
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
200with oneCompetencyCriterionSerializerrow per named criterion, inrequest 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 branchbehind 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 ontomainand open itagainst 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:bulk_update_competency_criteria()andCompetencyCriterionArchivedError, bothpublic and added to
__all__._validate_rule_source()validates the batch's rule source once, not once per criterion._apply_rule_source()writes one criterion's three rule columns in a single save, andskips the save when nothing would change.
_get_system_default_rule_profile()looks up the system default once per batch._resolve_applicable_rule_profile()is a stand-in for [OOS - BE] Reassign existing Competency Criteria when a taxonomy-level rule profile is created openedx/openedx-core#679's shared resolution helper(see "Deviations from [BE] Update/Edit Competency Criteria openedx/openedx-core#759").
src/openedx_learning/applets/cbe/rest_api/v1/serializers.py: addsCompetencyCriterionBulkUpdateSerializer. It checks field types only and rejects keys itdoesn't recognize.
src/openedx_learning/applets/cbe/rest_api/v1/views.py: addsCompetencyCriterionBulkUpdateView, which accepts only PATCH. It parses the body, calls theAPI function, converts Django's
ValidationErrorto DRF's, and convertsCompetencyCriterionArchivedErrorto a409.src/openedx_learning/applets/cbe/rest_api/v1/urls.py: registers the route ascriterion-bulk-update.tests/openedx_learning/applets/cbe/test_api.pyandtest_views.py: tests covering thescenarios 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 historyrow 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 thebatch changes. This check runs after every
400and404check, as the ticket's check orderrequires:
409means 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 unsavedCompetencyCriterion. The payload rules(allowed
opvalues, a threshold in[0.0, 1.0], onlyGradeimplemented) stay in the modellayer.
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_userset from the caller, so attribution doesn't depend onHistoryRequestMiddleware._change_reasonof"Rule values set"or"Reassigned to rule profile".QuerySet.update()is never used, because it skipspost_saveand would write no history.The permission check lives in the API function, not on the view. It checks
oel_tagging.can_tag_objectagainst the group's taxonomy and the leaf's course. Leaf groupshave no
course_id, so the course comes from the leaf's course-level parent. An in-processStudio 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.
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 leafoel_tagging.can_tag_objectfor the group's taxonomy and courseDeviations from openedx#759
_resolve_applicable_rule_profile()returns the systemdefault, or
Noneonce the default is archived. In this phase the system default is the onlyprofile 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.
rule_profile_id, not the ticket'scompetency_rule_profile_id. Onthe base branch,
CompetencyCriterionSerializerusesrule_profile_idin both requests andresponses, and this endpoint matches it. The Python API keeps the ticket's
competency_rule_profile_idkeyword.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.
CompetencyCriterion.archived. [BE] Add archived field to Competency Criteria & Group models openedx/openedx-core#716 is already inmain.helper) and [EPIC-CC] Competency Guardrails openedx/openedx-core#723 (Studio's learner-status warning and confirmation).
associations, and the rule that a criterion has either a profile or its own values.
appended to.
Testing instructions
Automated:
Manual steps. Use a Studio instance with
api/cbe/mounted (the one-linecms/urls.pycompanion change described in openedx#847). Log in as a user with Studio write
access to the course and view access to the competency taxonomy.
POST /api/cbe/v1/competencies/<tag_id>/criteria/. Pass the first response'sgroup_idinthe second request so both criteria land in the same leaf. Note both criterion ids and the
group_id.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 haverule_profile_id: nulland the submitted values.{"scale": "percent", "value": 0.8, "op": "gte"}(the key order is deliberately different).Expect both rows to follow the default again:
rule_profile_idset and both overridefields
null.200, and no new history rows. To check the history, runthis in a Django shell:
Reassigned to rule profile(step 3) andRule values set(step 2), both attributed to your user.
rule_profile_idtogether with the override pair:400onrule_profile_id."value": 75instead of a fraction:400.999999, added tocriterion_ids:404.400ongroup_id.403.<b>in a Django shell:409naming<b>, and criterion<a>unchanged.Verification
Run locally on
35917f0:pytest tests --no-cov: 1167 passed, 1 skipped. There are also 2 errors, intests/openedx_tagging/test_migrations.py; they happen the same way onmainin myenvironment and aren't touched by this PR.
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