Repository navigation
feat: exclude archived criteria and groups from the status rollup - #3
Closed
alezconsultant wants to merge 19 commits into
Closed
alezconsultant wants to merge 19 commits into
alezconsultant wants to merge 19 commits into
Conversation
Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 10.3.0 to 10.3.2. - [Release notes](https://github.com/astral-sh/setup-uv/releases) - [Commits](astral-sh/setup-uv@1c37ad0...07a047e) --- updated-dependencies: - dependency-name: astral-sh/setup-uv dependency-version: 10.3.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com>
Bumps [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish) from 1.14.2 to 1.14.3. - [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases) - [Commits](pypa/gh-action-pypi-publish@dc37677...7245906) --- updated-dependencies: - dependency-name: pypa/gh-action-pypi-publish dependency-version: 1.14.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com>
…/pypa/gh-action-pypi-publish-1.14.3 build(deps): bump pypa/gh-action-pypi-publish from 1.14.2 to 1.14.3
…/astral-sh/setup-uv-10.3.2 build(deps): bump astral-sh/setup-uv from 10.3.0 to 10.3.2
…er-independent (openedx#877) A taxonomy import with previous_id (openedx#673) can rename a tag's external_id, swap ids between tags and move tags between parents in one file. UpdateParentTag.applies_for compared a row's parent_id with the parent's pre-import external_id, so a child row that kept the old id was "no change" and _validate_parent never ran for it. When the same file renamed that parent away, the stale reference was accepted silently. Parent references also only worked when the parent's row came first in the file. The plan now computes the tree as it will be after the import (_end_state), once, and resolves every parent_id against it: - A row whose parent_id is its parent's pre-import id, when the file renames that id away, is rejected at the Plan step. After a plain rename the id names nothing; after a swap it names a different tag, so the row is ambiguous. A child naming its parent's final id is accepted. A case-only rename does not count as renaming the id away, since external_id is case-insensitive. - Parent cycles and trees deeper than the limit are rejected at the Plan step. Tag.save() only validates the saved tag's depth and silently corrupts lineage on a cycle. - Rows are built after the row that creates or renames-in their parent, so a child may precede its parent in the file. - Every existing tag whose parent changes gets a DetachTag action, listed in the plan after staging and before the row actions. After the detaches each tag is at or above its final depth and none is an ancestor of its new parent, so no row action can overflow or form a cycle part-way. - With replace=True, kept children of a deleted tag are promoted to the root before the first delete, so the cascade cannot remove a tag the file keeps, and a child whose deleted parent's id is reclaimed by a rename follows the renamed tag. Follow-ups: openedx#892 (the "Unknown parent tag" error names its row, and a cycle made only of new tags is reported as a cycle) and openedx#893 (reject a parent_id that names a tag the same replace=True import deletes). Closes openedx#858. Replaces openedx#867. Co-authored-by: David Ormsbee <dave@axim.org> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Adds GET /api/cbe/v1/competencies/<tag_id>/criteria-groups/, which returns a competency's criteria tree as two flat lists, groups and criteria, for the client to rebuild. - The caller must be authenticated and must pass oel_tagging.view_taxonomy on the tag's taxonomy, the rule openedx-platform overrides with its org-aware check. An unknown or non-competency tag is a 404. - Only groups whose whole ancestor chain is non-archived are returned, with the non-archived criteria under them, so every parent_id and group_id in the response names a returned row. Pruning happens in memory after one groups query; the criteria query covers only the kept groups. - Groups are ordered by parent (roots first), ordering, then id, and criteria by group, then id, so the response is stable across databases. - get_competency_criteria_tree(tag) takes the resolved Tag and returns CompetencyCriteriaTree, both in api.py and both UNSTABLE. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reject a CompetencyCriterion at creation time if any taxonomy ancestor or descendant of its competency tag already has a live CompetencyCriterion in the same course. Siblings, and a repeat criterion for the same tag in the same course, stay allowed. Archived criteria, and criteria under an archived leaf, course-level, or root group, do not count, since ADR 0002 hides archived rows from authoring and new associations. Add to openedx_tagging/api.py: get_ancestor_tags(tag) and get_descendant_tags(tag), both returning a QuerySet[Tag], mirroring Tag.descendant_count's own lineage-prefix mechanism and its free-text-taxonomy guard. Add to applets/cbe/api.py: get_courses_with_relative_criteria(tag_ids, course=None), a public function mapping each course already blocked by one of the given tags to the lowest-id conflicting tag, narrowed to one course when course is given. It only counts criteria on a leaf group, whose parent carries the course, so a criterion misplaced on another group is skipped rather than failing every create for the tag. Add _validate_containment(group, course), called from create_competency_criterion() before the object is tagged. create_competency_criterion() takes the caller's course_run instead of re-parsing object_id and fetching the course run a second time, and its rule fields are keyword-only. It runs inside the transaction and locks that associate_competency_criterion() holds. Add CompetencyCourseRun, a CBE-owned row whose primary key is a OneToOneField to the catalog's CourseRun (migration 0016). associate_competency_criterion() creates it with get_or_create() on a course's first criterion and locks it with select_for_update() right after the Tag lock, so two concurrent creates in the same course cannot both pass the containment check. Locking this row instead of CourseRun keeps the lock from blocking other apps' inserts that reference the course run, and keeps openedx_learning from querying catalog tables directly. Related to openedx#666 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Capture that pathway enrollments PROTECT their CatalogPathway, that all Catalog Pathways are public in Willow, and that we intend to add a Catalog model in Xylon to manage visibility. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…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.
Add CompetencyCriteriaGroup.scope_code, set in save() to "tag:<tag_id>" for a root and null for every other group, with a unique constraint on it. Until now one root per competency relied on the Tag row lock that associate_competency_criterion() takes: MySQL has no partial unique indexes, and the (tag, course, parent) constraint treats a root's null course and parent as distinct, so two roots for one competency would both save. The lock stays, but it now only serializes the duplicate-criterion check. A group's tag, parent and course are immutable after creation; save() rejects a change to any of them with a ValidationError, so a root's code never has to change. An archived root keeps its code and is reactivated rather than replaced: each learner has one StudentCompetencyStatus row per competency, derived from one root, so a live root beside archived ones would force every rollup and read to pick one. A check constraint allows a code only on a root. It runs one way, "a coded row is a root", because MySQL cannot defer constraint checks and Django's delete collector nulls parent and course before cascading. Below the root the column stays null: a leaf has no natural key to put there, and the one-way check means non-null would not make the rule stronger. Migration 0017 raises DuplicateRootCriteriaGroupsError on existing duplicate roots before any schema change, since MySQL commits each schema change immediately, then adds the column, backfills roots, and adds both constraints. ADR-0002 is amended: Decision 2 gains scope_code and the immutable columns, Decision 3 contrasts the two scope_code columns, Decision 7 states the MVP archive rule, and Rejected Alternative 7 records the replace-the-root design. Related to openedx#872 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Documents the commands, package layering, per-app API conventions, applet structure, publishing model, database compatibility rules (SQLite/MySQL/PostgreSQL, READ COMMITTED, constraints over locks), and test conventions so that agent sessions start with the repo's architecture and constraints already in context. The guidelines are tool-agnostic, so publish them under the generic AGENTS.md name that other coding agents read. Claude Code reads AGENTS.md natively from v2.1.277 when no CLAUDE.md exists, but a CLAUDE.md or CLAUDE.local.md anywhere up the tree shadows it, and older sessions read only CLAUDE.md. A one-line CLAUDE.md that imports AGENTS.md covers both cases. Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…#911) GET /api/cbe/v1/competencies/<tag_id>/criteria-groups/ accepts an optional course_keys query parameter, a comma-separated list of course keys. Omitting it returns every reachable group, as before. Supplying it returns the groups above any course plus the groups of the named course runs, in the order requested. A course run's groups are its course-level group and every group nested below it, at any depth. An empty value returns only the groups above any course. get_competency_criteria_tree() takes course_keys and assigns each group to its course run while it walks the tree from the roots, since a leaf group does not carry its course. Keys that match no course run are dropped, a repeated key counts once at its first position, a malformed key is a 400, and more than 100 keys is a 400. The criteria query is limited to the returned groups. The response adds criteria_count, the number of criteria returned, and total_criteria_count, the number under every reachable group before scoping. A scoped read gets the total from a separate COUNT; an unscoped read uses the length of the criteria list. openedx_catalog gains get_course_run_ids(), which resolves course keys to CourseRun ids in one query. Implements openedx-core#682 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…penedx#822) Co-Authored-By: Claude <noreply@anthropic.com>
Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 10.3.2 to 10.3.3. - [Release notes](https://github.com/astral-sh/setup-uv/releases) - [Commits](astral-sh/setup-uv@07a047e...68559e9) --- updated-dependencies: - dependency-name: astral-sh/setup-uv dependency-version: 10.3.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com>
Add roll_up_competency_statuses, which recomputes every learner status above the criteria attached to the given object ids: each criteria group up to the root, the competency at the root, and every parent competency up to the top of the taxonomy. It runs in autocommit, takes no lock, and every write only raises a stored status, so concurrent, repeated and out-of-order runs for the same learner converge on the right values. Related to openedx#643 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…/astral-sh/setup-uv-10.3.3 build(deps): bump astral-sh/setup-uv from 10.3.2 to 10.3.3
|
Hi @alezconsultant, thank you for the PR. In line with our process, could you please:
Thank you! |
Filter archived rows out of the two queries that fetch a group's children, so an archived criterion or criteria group does not count when a learner's group and competency statuses are combined. A group whose children are all archived has no status to report, so the rollup writes nothing for it. An archived group that the walk reaches is passed through, so its parent is still recomputed from its live children, but it gets no status row and, if it is a root, no competency row. The investigation warning for a sub-competency with no criteria beneath it ignores archived root groups, so a fully archived branch warns like one that was never authored. ADR-0002 Decision 6 records the evaluation rule. Statuses a learner already holds are never lowered or removed, so mastery earned through a requirement that was archived later stays earned. A parent competency's own archived root group counts through its stored status. Archiving does not trigger a rollup; a recomputation happens on the next grade for any criterion in that group or beneath it. Leaf evaluation skips archived criteria when a grade is recorded. The rollup's entry query does not, so a rollup call naming an archived criterion's content still recomputes that group from its live children. Related to openedx#815 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
alezconsultant
force-pushed
the
alezconsultant/815-exclude-archived-from-rollup
branch
from
November 6, 2026 14:51
ff13511 to
0963346
Compare
Author
|
Superseded by , rebased onto openedx/main after openedx#870 merged. |
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
An archived
CompetencyCriterionorCompetencyCriteriaGroupstops counting toward a learner'sstatus the next time the rollup runs. Every status a learner already holds is kept. The rollup
combines only live children, so an archived requirement the learner never met cannot keep a group
short of "demonstrated".
The rollup reads a group's children through two queries,
_child_groupsand_child_criteria.Both require
archived=False, so an archived child is invisible to its parent at the pointit is fetched.
combine_childrenreturns no status when no child has one, andraise_statusnever lowers ordeletes a stored row, so the kept-status and empty-group cases follow from the two filters.
Changes
src/openedx_learning/applets/cbe/rollup.py:_child_groupsand_child_criteriafilterarchived=False._roll_up_groupsskips both status writes at a group that is itself archived, and still addsits parent group (or, for a root, its competency tag) to the walk.
_log_unearnable_sub_competenciesalso requiresarchived=False.roll_up_competency_statusessay so.docs/openedx_learning/decisions/0002-competency-criteria-model.rst: Decision 6 bullet and changelog entry (see ADR).tests/openedx_learning/applets/cbe/test_rollup_api.pyandtest_tag_rollup.py: see Tests.Behavior
about
archived. Leaf evaluation at grade time already skips archived criteria. The rollup's entry query does not, so a rollup call naming an archived criterion's content still recomputes that group from its live children; the scenario "a previously demonstrated requirement changes nothing until the rollup runs" relies on that.it writes no
StudentCompetencyCriteriaGroupStatusfor it, and if that group is a root itwrites no
StudentCompetencyStatuseither. Its parent is still recomputed from the parent'slive children, and a root's competency still goes into the parent-competency walk. When other
graded tags already reach a level of the parent walk, the archived root's tag adds no reads and two queries per additional parent (four on a first write). When it is the only tag reaching a level, it adds that level's six batched reads plus two per parent, at every level to the top. Compared with feat: roll up learner competency statuses above a graded criterion openedx/openedx-core#870 this change adds no queries, because feat: roll up learner competency statuses above a graded criterion openedx/openedx-core#870 already fed that tag into the walk, and it removes the archived root's own writes.
write. An existing row is never deleted, and
raise_statusnever writes a lower value. Alearner who earned mastery through a requirement that was archived later keeps it.
_own_criteria_statusesis unfiltered, on purpose. A stored status on a parentcompetency's archived root group still counts toward that competency's own result, so a parent
that already reached "demonstrated" through it keeps doing so. This is consistent with "already
earned is kept".
"has no criteria beneath it and can never be demonstrated" warning, the same as one that was
never given requirements. A live root whose criteria are all archived is not reported, because
the query does not look at criteria (a known limitation from the ticket).
group status changes when the rollup next runs for a criterion in that group or beneath it, or
when the operator recomputation command ([BE] Recompute learner competency statuses on operator command openedx/openedx-core#774, not built yet) runs. Leaf evaluation skips
archived criteria, so a grade for an archived requirement writes no leaf row and returns a zero
count. The task drafted in openedx/edx-platform #39179 enqueues the rollup only when that count
is non-zero, so under that draft the acceptance scenario's "new grade for that requirement"
trigger is reachable only through the operator command.
Tests
Seventeen new tests. Ten of them cover the eleven acceptance scenarios and are named after the
scenario; the archived-branch and never-authored investigation cases share one test.
Six of the seventeen pass on the rollup without this change: the all-archived, kept-mastery, kept-unmet-status, and two parent-competency scenario tests, and the query-count guard. They pin the existing behavior each scenario relies on and give every scenario a named test; the other ten fail when the corresponding filter or guard is removed.
And in
test_tag_rollup.py: an archived root group still starts the parent-competency walk. Thearchived child gets no competency row, and the parent still reads its live sibling's stored
"demonstrated" and ends at "partially attempted".
The empty-collection case ("an AND group whose every child is archived") is the pure function
combine_children(op, []), whichtest_rollup_rules.py::test_combine_childrenalready covers.Verification
pytest tests/openedx_learning --no-cov: 430 passed on SQLite.pylint,pycodestyle,isort --check-only, andpydocstyleon the changed files: clean.mypyover the whole repository: no issues.lint-imports: all contracts kept.doc8on the changed ADR: clean.-W: clean.a scratch database migrated from scratch. Every expected value matched: both archived-requirement
cases ended at (30, 30), kept mastery stayed at 30 with
modifiedunchanged, the investigationwarning named the parent and the archived sub-competency once and the parent ended at 20, and
the unmet group status stayed at 10.
Notes for reviewers
group, so an author can add a live criterion under an archived root. The rollup then writes
that yet. An archived group whose criteria are still live gets no row, and its parent is
computed from its other live children. A test pins this.
counts toward that parent. This is left as is on purpose (see Behavior), so it has no new test.
Related to openedx#815
🤖 Generated with Claude Code