You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
As a Platform Administrator, I want to change how a Competency Criteria Group combines the criteria and groups beneath it, in order to correct the logic of a mastery rule without removing the whole branch and rebuilding everything under it.
Acceptance Criteria
Every outcome below is observable in the response to the update itself, in the learner status tables, or in the group's change history.
# Changing how a group combines its children
Scenario: Change a group from requiring all of its children to requiring any one
Given a Competency Criteria Group that requires all of its children
And the requesting user is permitted to manage the taxonomy that owns the group's competency
When the user updates the group to require any one of its children
Then the update succeeds
And the response reports the group as requiring any one of its children
Scenario: Change a group from requiring any one of its children to requiring all of them
Given a Competency Criteria Group that requires any one of its children
When the user updates the group to require all of its children
Then the update succeeds
And the response reports the group as requiring all of its children
# Rejecting a request the system cannot store
Scenario: Reject a way of combining children that the system cannot store
Given a Competency Criteria Group
When the user submits an update that names no way of combining the group's children, or names one the system does not support
Then the update is rejected
And the group still combines its children the same way as before the request
# Leaving alone what this endpoint does not change
Scenario: An update leaves everything else about the group and its branch untouched
Given a Competency Criteria Group with a name, a competency, a course scope, and a position in its tree
When the user updates how the group combines its children
Then the update succeeds
And the response reports the group's name, competency, course scope, and position in its tree unchanged
And every group and criterion beneath it is unchanged
Scenario: Reject an update that would change anything other than how the group combines its children
Given a Competency Criteria Group
When the user submits an update that would change the group's name, its evaluation order among its siblings, its competency, its course scope, or its parent
Then the update is rejected
And the response names the attribute that may not be changed
And the group is unchanged
Scenario: Accept an update that repeats attributes it may not change, when their values match
Given a Competency Criteria Group
When the user submits an update that restates the group's name and competency, matching what is stored, alongside a new way of combining its children
Then the update succeeds
And the response reports the new way of combining its children
Scenario: An update that changes nothing leaves no audit entry
Given a Competency Criteria Group
When the user submits an update carrying the way of combining children that the group already has
Then the update succeeds
And no new entry is added to the group's change history
# Requests that address nothing, or come from someone who may not make them
Scenario: Reject an update to a retired group
Given a Competency Criteria Group that has been retired
When the user submits an update to it
Then the update is refused as conflicting with the group's current state
And the group remains retired and otherwise unchanged
Scenario: Reject an update addressing a group under the wrong competency
Given a Competency Criteria Group that belongs to one competency
When the user submits an update addressing that group as though it belonged to a different competency
Then the request is treated as addressing something that does not exist
And no group is changed
Scenario: Reject an update to a group that does not exist
Given no Competency Criteria Group exists for the identifier in the request
When the user submits an update for it
Then the request is treated as addressing something that does not exist
And nothing is changed
Scenario: Reject an update from a user without permission
Given a user who is not permitted to manage the taxonomy that owns the group's competency
When that user submits an update to the group
Then the request is refused
And the group is unchanged
Description
A Competency Criteria Group can be created (#664) and retired together with everything beneath it (#675), but not changed once it exists. Correcting a group that combines its children the wrong way therefore means removing the whole branch and rebuilding every group and criterion under it. Where learners already have progress in that branch, #675 retires the branch rather than deleting it, so the rebuild leaves their recorded progress attached to retired rows.
This ticket makes exactly one thing editable in place: how the group combines the criteria and groups beneath it. The rule an individual criterion is evaluated by is edited on that criterion (#759), not here.
Technical Details
This section is background and a suggested approach, not the source of truth. The User Story and Acceptance Criteria define what must be true when the work is done; the notes below exist to save the implementer some thinking.
In short
What an author edits. One field: the AND/OR operator that decides how the group combines its children. That operator is the substance of a criteria group, and changing it changes how mastery is computed for every learner evaluated from that point forward.
Retirement is the only state that stops an edit. A group retired by #675 is not editable at all, and no confirmation can override that.
Where the logic lives, and how the audit trail stays honest. The endpoint is a thin adapter: resolve the group from the URL, check permissions, validate the request's shape, delegate to a function in the applet's api.py, serialize the result. Keeping the refusals and the audit attribution in the api function means an in-process Studio caller behaves identically to an HTTP one. ADR 0003 Decision 1 puts django-simple-history on this model, so an edit that changes nothing must not save at all, or the history fills with entries that record no change.
Api function.update_competency_criteria_group(group_id: int, *, logic_operator: str, user) -> CompetencyCriteriaGroup in src/openedx_learning/applets/cbe/api.py, added to that module's __all__. The umbrella src/openedx_learning/api.py re-exports by wildcard, so it needs no edit. logic_operator is a required keyword because it is the only editable field, so there is no partial-update case to model.
Accepted body.logic_operator, required. For name, ordering, parent_id, course_id, oel_tagging_tag_id, and archived: when one is present and its value matches what is stored, ignore it so a read-modify-write client can send the whole object back; when it differs, reject and name the field, rather than silently ignoring a failed authoring intent. Reject unrecognized keys.
Field validation. Accept only the model's AND and OR values, and not null, derived from the model field's choices so the check comes from one place. [BE] Build endpoint for creating a Competency Criteria Group #664 describes logic_operator as both required and defaulting to OR without reconciling the two; this endpoint requires an explicit AND or OR either way, so it does not depend on how that is resolved. Do not validate the group's child count: setting the operator on a group with no children or one child is accepted. Studio's authoring flow does not call this endpoint before a group has its first child, though; the operator choice on a still-childless group is held client-side and flows through the child's create request instead.
Check order. Resolve the group and check permissions; validate the request's shape; refuse a retired group; detect a no-op and return success without saving; save. Retirement precedes the no-op check because a retired row is not editable at all and a success response would imply the edit was accepted.
Status codes. 400 means the request is malformed or self-contradictory. 409 means the request is well formed but the state of the group blocks it.
Code
When
200
The operator changed, or the request was a no-op. Body is the group representation from CompetencyCriteriaGroupSerializer, echoing logic_operator.
400
logic_operator missing, null, or not AND/OR; an unrecognized key; or an attempt to change a field that is not editable.
403
The user lacks can_change_taxonomy on the taxonomy owning the group's competency.
404
No group for that id, or the group does not belong to the URL's competency_tag_id.
405
PUT.
409
The group is retired.
Single atomic write. Inside transaction.atomic(), one .save(update_fields=["logic_operator"]). Do not use queryset.update(): it does not fire post_save and would write no history row.
History attribution. Set instance._history_user from the user argument before saving, so attribution does not depend on the consumer installing simple_history.middleware.HistoryRequestMiddleware. No model in this repo uses HistoricalRecords() yet, so this establishes the convention rather than following one; django-simple-history is already a declared dependency in requirements/base.txt.
No learner status read or write. Do not create, update, or delete any StudentCompetency*Status row on any path, and do not query them either. There is no in-use check in this ticket.
Permissions. Reuse the permission class [BE] Build endpoint for creating a Competency Criteria Group #664 and [BE] Build endpoint for creating Competency Criteria when a gradeable-subsection association is selected #665 landed; do not add a new one. Confirm, when picking this up, that the check actually resolves to oel_tagging.change_taxonomy. DRF's DjangoObjectPermissions appears to build the required permission string from the view queryset's model rather than from the object passed to check_object_permissions. If so, a queryset of CompetencyCriteriaGroup would ask for a permission that is not registered with rules, and can_change_taxonomy would never run. TaxonomyTagsObjectPermissions in src/openedx_tagging/rest_api/v1/permissions.py shows the _queryset override that keeps such a check on Taxonomy. Treat this as unconfirmed against the installed DRF version rather than an established defect; the 403 test is what would surface it.
Setting the sibling sequence number, which has no defined semantics and no consumer.
Re-parenting a group. No frontend ticket asks for it, so no companion ticket is proposed until one does.
Changing course_id or oel_tagging_tag_id, which are fixed at creation.
Cascading a rule onto every criterion under the group, which is a multi-row write with no authoring control yet and no column on a criteria group to drive it.
Guarding against empty groups: creation and deletion are responsible for that, and this ticket never changes group membership.
Checking whether a learner has a recorded status for the group or anything beneath it, and any confirmation or warning workflow for it. Owned by [EPIC-CC] Competency Guardrails #723 (Epic 10).
Tests. One test per Acceptance Criteria scenario, plus the cases the scenarios do not reach. In tests/openedx_learning/applets/cbe/test_api.py: exactly one history row per real edit, and none for a no-op; a retired group refused even when the request is otherwise a no-op, which is what pins the check order; the operator accepted on a group with exactly one child; no StudentCompetency*Status row created, updated, or deleted, and none queried, on any path; the group's sequence number and parent unchanged by every path. In tests/openedx_learning/applets/cbe/test_views.py: one test per row of the status-code table above, including a body changing name, parent_id, or course_id.
Files to create and modify Modified files
File
Nature of modification
src/openedx_learning/applets/cbe/api.py
add update_competency_criteria_group() and list it in __all__
register the group detail route if #675 has not already
tests/openedx_learning/applets/cbe/test_api.py
api-level tests
tests/openedx_learning/applets/cbe/test_views.py
endpoint-level tests
Context ADR 0002, docs/openedx_learning/decisions/0002-competency-criteria-model.rst: Decision 2 for the group's fields, the empty-group rule, the sibling-ordering purpose of the sequence number, and the course-run date windowing that the course scope drives; Decision 3 for the precedent that a row's scope fields are immutable after creation.
ADR 0003, docs/openedx_learning/decisions/0003-competency-criteria-versioning.rst: Decision 1 for django-simple-history on this model; Decision 4 for the warning-and-confirmation workflow, which [EPIC-CC] Competency Guardrails #723 (Epic 10) implements in front of this endpoint rather than this endpoint itself; Decision 5 for learner status tables being append-only.
Prior art for the thin-view-plus-api-function shape and the error translation this ticket follows: src/openedx_tagging/rest_api/v1/views.py (TaxonomyTagsView.update) and src/openedx_tagging/api.py (update_tag_in_taxonomy).
Permission machinery: src/openedx_tagging/rules.py (can_change_taxonomy) and src/openedx_tagging/rest_api/v1/permissions.py (TaxonomyObjectPermissions, plus TaxonomyTagsObjectPermissions for the _queryset override pattern).
The applet-api convention this function is published through: docs/openedx_content/decisions/0010-merge-authoring-apps-into-openedx-content.rst, with src/openedx_content/api.py as the working example.
User Story
As a Platform Administrator, I want to change how a Competency Criteria Group combines the criteria and groups beneath it, in order to correct the logic of a mastery rule without removing the whole branch and rebuilding everything under it.
Acceptance Criteria
Every outcome below is observable in the response to the update itself, in the learner status tables, or in the group's change history.
Description
A Competency Criteria Group can be created (#664) and retired together with everything beneath it (#675), but not changed once it exists. Correcting a group that combines its children the wrong way therefore means removing the whole branch and rebuilding every group and criterion under it. Where learners already have progress in that branch, #675 retires the branch rather than deleting it, so the rebuild leaves their recorded progress attached to retired rows.
This ticket makes exactly one thing editable in place: how the group combines the criteria and groups beneath it. The rule an individual criterion is evaluated by is edited on that criterion (#759), not here.
Technical Details
This section is background and a suggested approach, not the source of truth. The User Story and Acceptance Criteria define what must be true when the work is done; the notes below exist to save the implementer some thinking.
In short
What an author edits. One field: the AND/OR operator that decides how the group combines its children. That operator is the substance of a criteria group, and changing it changes how mastery is computed for every learner evaluated from that point forward.
Retirement is the only state that stops an edit. A group retired by #675 is not editable at all, and no confirmation can override that.
Where the logic lives, and how the audit trail stays honest. The endpoint is a thin adapter: resolve the group from the URL, check permissions, validate the request's shape, delegate to a function in the applet's
api.py, serialize the result. Keeping the refusals and the audit attribution in the api function means an in-process Studio caller behaves identically to an HTTP one. ADR 0003 Decision 1 putsdjango-simple-historyon this model, so an edit that changes nothing must not save at all, or the history fills with entries that record no change.Implementation specifics
PATCH /cbe/rest_api/v1/competencies/<int:competency_tag_id>/criteria-groups/<int:group_id>/. DRF routes methods on one view class per path, so add apatch()method to the class [BE] Build endpoint for removing a Competency Criteria Group #675 registers at that detail path rather than a second class, renaming it toCompetencyCriteriaGroupDetailViewif it landed asCompetencyCriteriaGroupDeleteView; if [BE] Build endpoint for removing a Competency Criteria Group #675 has not landed, register the detail route here and let [BE] Build endpoint for removing a Competency Criteria Group #675 adddelete()to this class. Sethttp_method_namessoPUTis not offered. Resolve the group withget_object_or_404(..., pk=group_id, oel_tagging_tag_id=competency_tag_id), matching [BE] Build endpoint for removing a Competency Criteria Group #675's convention. The create route (competency-criteria-group-create,CompetencyCriteriaGroupCreateView) is on the collection path and is untouched.update_competency_criteria_group(group_id: int, *, logic_operator: str, user) -> CompetencyCriteriaGroupinsrc/openedx_learning/applets/cbe/api.py, added to that module's__all__. The umbrellasrc/openedx_learning/api.pyre-exports by wildcard, so it needs no edit.logic_operatoris a required keyword because it is the only editable field, so there is no partial-update case to model.logic_operator, required. Forname,ordering,parent_id,course_id,oel_tagging_tag_id, andarchived: when one is present and its value matches what is stored, ignore it so a read-modify-write client can send the whole object back; when it differs, reject and name the field, rather than silently ignoring a failed authoring intent. Reject unrecognized keys.ANDandORvalues, and not null, derived from the model field'schoicesso the check comes from one place. [BE] Build endpoint for creating a Competency Criteria Group #664 describeslogic_operatoras both required and defaulting toORwithout reconciling the two; this endpoint requires an explicitANDorOReither way, so it does not depend on how that is resolved. Do not validate the group's child count: setting the operator on a group with no children or one child is accepted. Studio's authoring flow does not call this endpoint before a group has its first child, though; the operator choice on a still-childless group is held client-side and flows through the child's create request instead.CompetencyCriteriaGroupSerializer, echoinglogic_operator.logic_operatormissing, null, or notAND/OR; an unrecognized key; or an attempt to change a field that is not editable.can_change_taxonomyon the taxonomy owning the group's competency.competency_tag_id.PUT.transaction.atomic(), one.save(update_fields=["logic_operator"]). Do not usequeryset.update(): it does not firepost_saveand would write no history row.instance._history_userfrom theuserargument before saving, so attribution does not depend on the consumer installingsimple_history.middleware.HistoryRequestMiddleware. No model in this repo usesHistoricalRecords()yet, so this establishes the convention rather than following one;django-simple-historyis already a declared dependency inrequirements/base.txt.StudentCompetency*Statusrow on any path, and do not query them either. There is no in-use check in this ticket.oel_tagging.change_taxonomy. DRF'sDjangoObjectPermissionsappears to build the required permission string from the view queryset's model rather than from the object passed tocheck_object_permissions. If so, a queryset ofCompetencyCriteriaGroupwould ask for a permission that is not registered withrules, andcan_change_taxonomywould never run.TaxonomyTagsObjectPermissionsinsrc/openedx_tagging/rest_api/v1/permissions.pyshows the_querysetoverride that keeps such a check onTaxonomy. Treat this as unconfirmed against the installed DRF version rather than an established defect; the 403 test is what would surface it.archived(a boolean field) on this model, and on [BE] Implement CBE core data models (CompetencyTaxonomy, criteria, learner status) #613 for the models. Confirm [BE] Implement CBE core data models (CompetencyTaxonomy, criteria, learner status) #613 before implementing rather than assuming.src/openedx_learning/applets/cbe/, imports only fromopenedx_taggingand Django or DRF, and adds no import beyond what [BE] Build endpoint for creating a Competency Criteria Group #664 establishes.openedx_learningis not yet among.importlinter's root packages; adding it belongs to [BE] Implement CBE core data models (CompetencyTaxonomy, criteria, learner status) #613.archived([BE] Build endpoint for removing a Competency Criteria Group #675).course_idoroel_tagging_tag_id, which are fixed at creation.tests/openedx_learning/applets/cbe/test_api.py: exactly one history row per real edit, and none for a no-op; a retired group refused even when the request is otherwise a no-op, which is what pins the check order; the operator accepted on a group with exactly one child; noStudentCompetency*Statusrow created, updated, or deleted, and none queried, on any path; the group's sequence number and parent unchanged by every path. Intests/openedx_learning/applets/cbe/test_views.py: one test per row of the status-code table above, including a body changingname,parent_id, orcourse_id.Files to create and modify Modified files
update_competency_criteria_group()and list it in__all__patch()to the group detail view and restricthttp_method_namesdocs/openedx_learning/decisions/0002-competency-criteria-model.rst: Decision 2 for the group's fields, the empty-group rule, the sibling-ordering purpose of the sequence number, and the course-run date windowing that the course scope drives; Decision 3 for the precedent that a row's scope fields are immutable after creation.docs/openedx_learning/decisions/0003-competency-criteria-versioning.rst: Decision 1 fordjango-simple-historyon this model; Decision 4 for the warning-and-confirmation workflow, which [EPIC-CC] Competency Guardrails #723 (Epic 10) implements in front of this endpoint rather than this endpoint itself; Decision 5 for learner status tables being append-only.src/openedx_tagging/rest_api/v1/views.py(TaxonomyTagsView.update) andsrc/openedx_tagging/api.py(update_tag_in_taxonomy).src/openedx_tagging/rules.py(can_change_taxonomy) andsrc/openedx_tagging/rest_api/v1/permissions.py(TaxonomyObjectPermissions, plusTaxonomyTagsObjectPermissionsfor the_querysetoverride pattern).docs/openedx_content/decisions/0010-merge-authoring-apps-into-openedx-content.rst, withsrc/openedx_content/api.pyas the working example.archivedas a retirement action); [BE] Build GET endpoint to fetch Competency Criteria Groups and Criteria for a competency #681 and [Placeholder for BE] Update the Get endpoint for Competency Criteria Groups to determine whether each is deletable and include this metadata in the returned payload. #685 (read endpoints); [BE] Add archived field to CompetencyCriteriaGroup and CompetencyCriterion #716 (thearchivedfield); [EPIC-CC] Competency Guardrails #723 (the guardrails these confirmations serve); [BE] Build Update endpoint to handle a user de-selecting a Competency Criteria association. #759 (the criterion-level update sibling).