Repository navigation
feat: add bulk update competency criteria endpoint - #874
Conversation
|
Thanks for the pull request, @javoconsultant! This repository is currently maintained by 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 approvalIf you haven't already, check this list to see if your contribution needs to go through the product review process.
🔘 Provide contextTo 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:
🔘 Get a green buildIf one or more checks are failing, continue working on your changes until this is no longer the case and your build turns green. DetailsWhere 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:
💡 As a result it may take up to several weeks or months to complete a review and merge your PR. |
d324a1c to
193898c
Compare
ormsbee
left a comment
There was a problem hiding this comment.
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:
- 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 onapi.py). Please use the same lookup in both places. - Please keep
Http404out ofapi.py, same as on #847 (inline). - Please call the keyword
rule_profile_id, to matchassociate_competency_criterion()(inline). - 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.
|
For documentation purposes, this was Unicon reviewed and approved in a prior PR here: ufedaseyeuconsultant#2 |
javoconsultant
left a comment
There was a problem hiding this comment.
@ormsbee i've addressed all your comments
ormsbee
left a comment
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
addressed in the last commit
9487813 to
c50e6b4
Compare
c50e6b4 to
eb0a019
Compare
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.
Description
Implements #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:
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 inmain, andthis PR targets
maindirectly.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:bulk_update_competency_criteria()andCompetencyCriterionArchivedError. Both arepublic, marked
UNSTABLElike the other criteria-authoring functions, and added to__all__.create_competency_criterion()into a private_get_applicable_rule_profile(taxonomy_id), which both the create path and the bulk updatecall. It returns the live profile scoped to the taxonomy, else the live system default, else
None.create_competency_criterion()still raisesCompetencyRuleProfile.DoesNotExistwhen neither profile exists.
_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 thesame 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, andskips the save when nothing would change.
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, and translates the function's exceptions:
CompetencyCriteriaGroup.DoesNotExistand
CompetencyCriterion.DoesNotExistbecome404, Django'sValidationErrorbecomes DRF's,and
CompetencyCriterionArchivedErrorbecomes409.api.pyitself raises noHttp404,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 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 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: thelive 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
detailnames the archived criteria or the archived group. Archiving a group doesn't archivethe 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:404) and be a leaf (400).403).criterion_idsmust be non-empty with no duplicates, and the rule source must be valid(
400).409).404).409).A
409means the request is well formed but the state of the batch blocks it. Steps 4 and 5lock the lineage groups and the named criteria with
select_for_update(). That way aconcurrent 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 unsavedCompetencyCriterion. The payload rules(allowed
opvalues, a threshold in[0.0, 1.0], onlyGradeimplemented) stay in the modellayer. The model reports a bad payload as a non-field error, and the API function re-keys it
under
rule_payload_override. Whenrule_type_overrideis itself refused, only that error isreturned, 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_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 #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, keyed byrule_payload_override; an unrecognized body key; a group that isn't a leafoel_tagging.can_tag_objectfor the group's taxonomy and coursedetailnames the ids that didn't resolve.detailnames the archived criteria or the archived group.Deviations from #759
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, movedinto
_get_applicable_rule_profile(). That lookup takes a taxonomy id, and every criterion ina 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.
keyword are both
rule_profile_id, and the Python API's group argument isgroup_id. Theticket has
competency_rule_profile_idandcompetency_criteria_group_id.rule_profile_idis the name
associate_competency_criterion(),create_competency_criterion(), the modelfield, and
CompetencyCriterionSerializeralready use.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 every404before any409, but the archived-group check runs before the criterion lookup. So a batch addressed toan archived group gets
409even if it also names an id outside the group.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.
main.CompetencyCriterion.archived. [BE] Add archived field to Competency Criteria & Group models #716 is already inmain.in front of this endpoint.
CompetencyCriterion.save()callsfull_clean()without excluding its foreign keys, and Django checks each foreign key with a
SELECT.Each changed criterion costs 4 queries with override values and 5 with a named profile, and
2 of those (3 with a profile) are these checks.
associations, archived criteria hidden from authoring, how a criterion is assigned its
applicable profile, 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 #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.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_idset to the profile step 1 assigned, and bothoverride fields
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:400onrule_payload_override."rule_type_override": "Completion":400onrule_type_overrideonly.999999, added tocriterion_ids:404, with thedetailnaming that id.
400ongroup_id.403.<b>in a Django shell:409naming<b>, and criterion<a>unchanged.<b>, then archive the leaf group, in a Django shell:409naminggroup <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, intests/openedx_tagging/test_migrations.py; they happen the same way onmainin myenvironment and aren't touched by this PR.
tests/openedx_learning/applets/cbe/: 332 tests, all passing, 71 of them newin 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