Repository navigation
feat: add PATCH endpoint for a competency criteria group's logic_operator (#760) - #913
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. |
tbain
left a comment
There was a problem hiding this comment.
This was previously approved at the Unicon level under an older version of this PR: ufedaseyeuconsultant#3 (review)
ormsbee
left a comment
There was a problem hiding this comment.
Two requests before merging, both small:
- Please keep
Http404out ofapi.py, same as on #847 and #874 (inline). - Please check that the caller can read the competency before the body gets compared against the stored group. Right now a caller who can't read the tree can still learn a group's
name,course_key, andparent_idfrom whether a guess gets a 400 or a 403 (inline on the view, with Claude's probe).
There are also a few smaller, non-blocking things in the inline comments. Thank you.
Claude drafted the first pass of this review and ran the CBE suite (449 tests), the quality checks (pylint, mypy, pycodestyle, pydocstyle, isort, import-linter), and makemigrations --check on 58290ed for me, all clean, plus the permission and query-count probes described inline.
ormsbee
left a comment
There was a problem hiding this comment.
Thank you for the update. Everything from the last round is addressed.
Two last things, and then I'll merge:
- Please update the PR description for the new permission order. The paragraph on the view, the 403 row, and the third "Deviations" bullet still say that the body is compared before any permission check.
- Please squash to one commit with the description's context in the commit body, since the first commit doesn't have one.
Thank you.
Claude drafted this review and re-ran the CBE suite (454 tests), the quality checks (pylint, mypy, pycodestyle, pydocstyle, isort, import-linter), and makemigrations --check on a9f130c for me, all clean. CI is green, and the branch merges cleanly with main.
…ator (openedx#760) Add PATCH /api/cbe/v1/competencies/<tag_id>/criteria-groups/<group_id>/ and update_competency_criteria_group(), which let an author change how one CompetencyCriteriaGroup combines the criteria and groups beneath it by setting its logic_operator to AND or OR. Nothing else about the group or its subtree changes, and no StudentCompetency*Status row is read or written. - The view is a thin adapter. It resolves the competency from the URL and checks oel_tagging.view_taxonomy, as the criteria-tree endpoint (openedx#681) does, before it looks up the group or compares the body, since both reveal what is stored. update_competency_criteria_group() makes every other refusal, including the oel_tagging.can_tag_object edit check, so an in-process caller such as Studio is refused in the same cases as an HTTP caller. - logic_operator is required and is the only field that can change. The body may also restate the other fields the criteria-tree endpoint returns for a group (id, parent_id, tag_id, course_key, name, ordering), so a client can send back the object it read. Each restated field must equal the stored value exactly (a name is compared without trimming). A field that differs, or a key outside that representation, is a 400 that names it. The response is the stored group in the criteria-tree shape. - A missing, null, or non-AND/OR logic_operator is a 400, as is a root group or a group with no course at or above it. A tag that is not a competency tag, an unknown group, or a group under a different competency is a 404. An archived group is a 409, through the new public CompetencyCriteriaGroupArchivedError and the existing CompetencyCriteriaConflict, even when the request would change nothing. - The permission course is a course-level group's own course, and for any group below one, the course of its nearest ancestor that has one, however deeply it is nested. - The update is one save(update_fields=["logic_operator"]) inside transaction.atomic(), with _history_user set to the caller, so django-simple-history records exactly one row per real change, attributed to that user (ADR 0003 Decision 1). Asking for the operator the group already has returns 200 without saving, so it adds no history row. - update_competency_criteria_group() checks for a root group, resolves the course, checks permission, validates the operator value, refuses an archived group, and then skips the save if nothing changed. The archived check runs before the no-op check so an archived group is never reported as successfully edited. Deviations from openedx#760: - The ticket lists course_id, oel_tagging_tag_id, and archived among the fields a client may restate. The endpoint instead accepts the fields the criteria-tree endpoint returns, under their REST names (course_key, tag_id), plus id. archived is not part of that representation, so a body that includes it is rejected as an unrecognized key. - The ticket resolves the permission course from either a course-level group or a leaf. The endpoint walks up to the nearest ancestor with a course, so a group nested below a leaf can also be edited. A non-root group with no course anywhere above it is rejected with 400 before the permission check. - The ticket orders the permission check before request validation. Here it is split in two: the view checks read access first, then the body's shape (missing or null operator, unrecognized keys, changed fixed fields), which stays in the view because only a REST body carries the fixed fields. The edit check runs after that, inside the API function, followed by the operator value check. A caller who can read the tree but cannot edit the group's course therefore gets 400, not 403, for a malformed body. Related: openedx#675 removes a group and its subtree at the same path. Django dispatches a path to the first pattern that matches it, so whichever PR lands second should add its method to the other PR's view class rather than register a second route. If this lands first, openedx#675 adds delete() to CompetencyCriteriaGroupDetailView and "delete" to its http_method_names. openedx#723 (Epic 10) owns the learner-status warning that will sit in front of this endpoint.
a9f130c to
27dfbac
Compare
@ormsbee all done |
Regenerated via `make upgrade-package package=openedx-core`. Picks up openedx/openedx-core#913 (PATCH endpoint for a competency criteria group's logic_operator, openedx/openedx-core#760), first released in 1.18.0, so the Competency-Based Education (CBE) work can go to QA.
Closes #760.
What this adds
This PR adds
PATCH /api/cbe/v1/competencies/<tag_id>/criteria-groups/<group_id>/andupdate_competency_criteria_group()to the CBE applet's public API. Together they let an author change how one CompetencyCriteriaGroup combines the criteria and groups beneath it, by setting itslogic_operatortoANDorOR. Nothing else about the group or its subtree changes, and noStudentCompetency*Statusrow is read or written.The view is a thin adapter. It resolves the competency from the URL and first checks that the caller can read the competency's criteria tree, using the same
oel_tagging.view_taxonomycheck as the criteria-tree endpoint (#681). Only then does it look up the group and check the request body against the stored group, because both steps reveal what is stored. Finally it callsupdate_competency_criteria_group(). That function makes every other refusal, including the edit permission check, so an in-process caller such as Studio is refused in the same cases as an HTTP caller.Request body
logic_operatoris required, and it is the only field that can change:{"logic_operator": "OR"}The body may also restate any other field that the criteria-tree endpoint (#681) returns for a group:
id,parent_id,tag_id,course_key,name, andordering. A client can therefore send back the object it read with only the operator changed. Each restated field must exactly equal the stored value; for example, a name is compared without trimming whitespace. A restated field that differs is rejected and named in the error. So is any key that the group representation does not include.The response is the group as stored, in the same shape the criteria-tree endpoint returns.
Responses
logic_operatoris missing, null, or notAND/OR. Also returned when the body has an unrecognized key or changes a fixed field, when the group is a root, or when no group at or above the target has a course to check permission against.oel_tagging.view_taxonomy). That check runs before the group is looked up or the body is compared, so such a caller gets 403 whatever group ID or body it sends. Also returned when the caller failsoel_tagging.can_tag_objectfor the competency's taxonomy and the group's course.Behavior worth reviewing
save(update_fields=["logic_operator"])insidetransaction.atomic(), with_history_userset from the caller. As a result,django-simple-historyrecords exactly one row per real change, attributed to that user (ADR 0003 Decision 1). A request for the operator the group already has returns 200 without saving, so it adds no history row.update_competency_criteria_group(). The function checks for a root group, resolves the course, checks permission, validates the operator value, refuses an archived group, and then skips the save if nothing changed. The archived check runs before the no-op check so that an archived group is never reported as successfully edited.CompetencyCriteriaGroupArchivedErroris raised for an archived group. The view maps it to 409 through the existingCompetencyCriteriaConflict.Deviations from #760
course_id,oel_tagging_tag_id, andarchivedamong the fields a client may restate. Instead, the endpoint accepts the fields that the criteria-tree endpoint returns, under their REST names (course_key,tag_id), plusid.archivedis not part of that representation, so a body that includes it is rejected as an unrecognized key.oel_tagging.can_tag_object) runs after it, inside the API function, followed by the check that the operator isANDorOR. As a result, a caller who can read the tree but cannot edit the group's course gets 400, not 403, for a malformed body.Related work
CompetencyCriteriaGroupDeleteViewthere. Django dispatches a path to the first pattern that matches it, so whichever PR lands second should add its method to the other PR's view class instead of registering a second route. If this PR lands first, [BE] Build endpoint for removing a Competency Criteria Group #675 would adddelete()toCompetencyCriteriaGroupDetailViewand add"delete"to itshttp_method_names.Testing
tests/openedx_learning/applets/cbe/test_api.pycovers each acceptance-criteria scenario at the API level. It also covers these cases:tests/openedx_learning/applets/cbe/test_views.pycovers every status code above. That includes each fixed field being changed, a restated name being compared exactly, and a client sending back the representation it read from the criteria-tree endpoint.On this branch, all 449 tests in
tests/openedx_learning/applets/cbepass, and pylint, mypy, pycodestyle, isort, and lint-imports are clean.🤖 Generated with Claude Code