Skip to content

feat: exclude archived criteria and groups from the status rollup - #3

Closed
alezconsultant wants to merge 19 commits into
alezconsultant/643-rollup-competency-statusesfrom
alezconsultant/815-exclude-archived-from-rollup
Closed

alezconsultant wants to merge 19 commits into
alezconsultant/643-rollup-competency-statusesfrom
alezconsultant/815-exclude-archived-from-rollup

Conversation

@alezconsultant

Copy link
Copy Markdown

Description

An archived CompetencyCriterion or CompetencyCriteriaGroup stops counting toward a learner's
status 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_groups and _child_criteria.
Both require archived=False, so an archived child is invisible to its parent at the point
it is fetched. combine_children returns no status when no child has one, and raise_status never lowers or
deletes 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_groups and _child_criteria filter archived=False.
    • _roll_up_groups skips both status writes at a group that is itself archived, and still adds
      its parent group (or, for a root, its competency tag) to the walk.
    • The root-group query in _log_unearnable_sub_competencies also requires archived=False.
    • The docstrings of the touched functions and of roll_up_competency_statuses say 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.py and test_tag_rollup.py: see Tests.

Behavior

  • Filters are applied where children are fetched. Nothing else in the combination logic knows
    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.
  • An archived group is walked through but gets no row. If the walk (the rollup's level-by-level pass from the graded criteria up to each root group, then up the competency tags) reaches an archived group,
    it writes no StudentCompetencyCriteriaGroupStatus for it, and if that group is a root it
    writes no StudentCompetencyStatus either. Its parent is still recomputed from the parent's
    live 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.
  • Nothing is lowered or removed. A group with no live children gets no status, which means no
    write. An existing row is never deleted, and raise_status never writes a lower value. A
    learner who earned mastery through a requirement that was archived later keeps it.
  • _own_criteria_statuses is unfiltered, on purpose. A stored status on a parent
    competency'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".
  • Investigation warning. A sub-competency whose root group is archived is reported by the
    "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).
  • Archiving never triggers a rollup. Nothing here calls the rollup from a save or signal. A
    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. The
archived 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, []), which test_rollup_rules.py::test_combine_children already covers.

Verification

  • pytest tests/openedx_learning --no-cov: 430 passed on SQLite.
  • MySQL 8 on the Tutor devstack: the same CBE suite, 430 passed.
  • pylint, pycodestyle, isort --check-only, and pydocstyle on the changed files: clean.
  • mypy over the whole repository: no issues.
  • lint-imports: all contracts kept.
  • doc8 on the changed ADR: clean.
  • Sphinx HTML build with -W: clean.
  • Manual test on a Tutor devstack (MySQL 8): the script above, run with this branch's code against
    a scratch database migrated from scratch. Every expected value matched: both archived-requirement
    cases ended at (30, 30), kept mastery stayed at 30 with modified unchanged, the investigation
    warning 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

Related to openedx#815

🤖 Generated with Claude Code

edx-requirements-bot and others added 10 commits October 26, 2026 10:47
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.
alezconsultant and others added 8 commits October 29, 2026 22:17
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>
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
@jesperhodge

Copy link
Copy Markdown

Hi @alezconsultant, thank you for the PR. In line with our process, could you please:

  • Run /backend-code-review on Fable medium effort for this PR and attach the file it gives you to a comment in this PR
  • Evaluate its findings and fix points you agree with, push back on others
  • Create a fix report like this and attach it to a comment in this PR - you can use this as AI prompt:

Write <workspace root>/pr-reviews/<N>-fix-report.md, next to the review, a Markdown file with three tables:

  • Fixed: the finding, the fix, and where it was made. Each cell is one sentence; a fix with several parts may take more.
  • Not fixed: the finding and the reason it was not fixed.
  • Raised separately: the finding and where it went (a product owner, a maintainer, a follow-up ticket).

End it with the verification that ran after the fixes and its results.

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
alezconsultant force-pushed the alezconsultant/815-exclude-archived-from-rollup branch from ff13511 to 0963346 Compare November 6, 2026 14:51
@alezconsultant

Copy link
Copy Markdown
Author

Superseded by , rebased onto openedx/main after openedx#870 merged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants