Skip to content

feat: enforce competency-hierarchy dominance for criteria - #2

Closed
alezconsultant wants to merge 44 commits into
alezconsultant/665-criterion-endpointfrom
alezconsultant/666-competency-hierarchy-dominance
Closed

alezconsultant wants to merge 44 commits into
alezconsultant/665-criterion-endpointfrom
alezconsultant/666-competency-hierarchy-dominance

Conversation

@alezconsultant

Copy link
Copy Markdown
Owner

Description

Implements openedx#666: before a CompetencyCriterion is created, reject it with
a 400 if any taxonomy ancestor or descendant of its competency tag already has a
CompetencyCriterion in the same course. This is the containment check
create_competency_criterion() reserved a seam for in openedx#665. Siblings, and a repeat criterion
for the same tag in the same course, stay allowed, per the ticket's own acceptance criteria.

Branched off alezconsultant/665-criterion-endpoint (PR openedx#14 into
jesperhodge/feat--641-competency-criteria-models), since that's where the models and the
create-criterion endpoint this check attaches to actually live.

Changes

  • src/openedx_tagging/api.py: adds get_ancestor_tags(tag) and get_descendant_tags(tag),
    both -> QuerySet[Tag], matching the ticket's own approved technical notes exactly (name,
    file, and signature). Mirrors Tag.descendant_count's lineage-prefix mechanism and its
    free-text-taxonomy guard.
  • src/openedx_learning/applets/cbe/api.py: adds get_courses_with_relative_criteria(tag_ids)
    (public, also listed in __all__) and _validate_containment(group, course) (private).
    Wires the latter into create_competency_criterion() at its existing reserved insertion
    point, replacing the marker comment with a three-line addition (two assert calls narrowing
    group.parent's type, then the validation call).
  • tests/openedx_tagging/test_api.py, tests/openedx_learning/applets/cbe/test_api.py,
    tests/openedx_learning/applets/cbe/test_views.py: unit tests for the two new tagging
    functions and for get_courses_with_relative_criteria/_validate_containment in isolation,
    plus one endpoint test per acceptance-criteria scenario in the ticket (all six), each with its
    own uniquely-prefixed tag family so scenarios can't interfere with each other. The three
    reject-scenario tests assert the response body actually names the conflicting tag and course,
    not just that an error key is present, per the ticket's own acceptance criteria.

Manual Testing

Due to a requirements mismatch and incorrect migrations, this section will be updated once the changes from openedx#800 are merged. Until then, rely on unit tests.

Related to openedx#666

🤖 Created with the help of Claude Code

irfanuddinahmad and others added 10 commits September 3, 2026 16:53
Modernize openedx-core to uv + pyproject.toml (PEP 621/735) +
python-semantic-release.

Part of openedx/public-engineering#506 (tracked in
openedx/public-engineering#516).

- Replace setup.py/setup.cfg with pyproject.toml (PEP 621 static
  metadata), version derived from git tags via setuptools-scm.
- Switch from pip-compile to uv with PEP 735 dependency groups;
  commit uv.lock.
- Update tox.ini to use tox-uv with uv-venv-lock-runner.
- Update CI to use astral-sh/setup-uv; SHA-pin all actions.
- Add python-semantic-release + release.yml, replacing the old
  token-based pypi-publish.yml with OIDC trusted publishing.
- src/ layout not touched -- this repo already used it, so no change
  was needed here.
- Delete setup.py, setup.cfg, .coveragerc, requirements/ (all
  .in/.txt files, constraints.txt, private.readme),
  .github/workflows/pypi-publish.yml.

Addresses salman2013's PR review feedback:
- Keep the CI job name as tests, matching the pre-migration name
  (deferring the per-toxenv rename to avoid breaking
  required-status-check matching).
- __version__ falls back to "unknown" on PackageNotFoundError instead
  of leaving it unset, with the except branch marked
  pragma: no cover since the package is always installed in CI.
- Remove the legacy, unused .readthedocs.yml (superseded by
  .readthedocs.yaml).
- Drop an unintended fetch-depth: 0 on the checkout step -- a
  leftover side effect of removing the separate setup-python step,
  not a deliberate choice.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ernize-python-tooling

# Conflicts:
#	requirements/base.txt
#	requirements/ci.txt
#	requirements/dev.txt
#	requirements/doc.txt
#	requirements/quality.txt
#	requirements/test.txt
…penedx#803)

Add taxonomy_type as a write-only ChoiceField on TaxonomySerializer ("tags" default,
"competency" via a new TaxonomyType enum). TaxonomyView.perform_create() and
create_import() discard it after validation: openedx_tagging must never import
openedx_learning, so it can't act on it. create_import()'s taxonomy-creation step is
extracted into an overridable _create_taxonomy_for_import() hook for subclasses.

Add create_competency_taxonomy() to the CBE applet: creates the Taxonomy and linked
CompetencyTaxonomy row in one transaction, via save_base(raw=True) since save() would
re-save Taxonomy with unpopulated field values.

Add CompetencyTaxonomyView(TaxonomyView), overriding perform_create() and
_create_taxonomy_for_import() to dispatch to create_competency_taxonomy() when
taxonomy_type="competency". Lives here since this is the layer that can see both
openedx_tagging and openedx_learning.

See openedx#628

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…mad/modernize-python-tooling

# Conflicts:
#	requirements/base.txt
#	requirements/ci.txt
#	requirements/dev.txt
#	requirements/doc.txt
#	requirements/pip-tools.txt
#	requirements/quality.txt
#	requirements/test.txt
#	src/openedx_core/__init__.py
…e job

Matches openedx/sample-plugin's current release.yml; the custom
OPENEDX_SEMANTIC_RELEASE_GITHUB_TOKEN secret isn't needed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
alezconsultant pushed a commit that referenced this pull request Sep 24, 2026
… PR branch

The stack on jesperhodge/openedx-core (openedx#10, #2, #3, #4, #5, #6, openedx#7) is where
the openedx#641 code is written and reviewed. This branch only carries the stack tip's
tree so that openedx#800 has something to squash-merge.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…openedx#818)

AI note: Claude slightly assisted with this, though most of it was written by me.

@tbain tbain left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hello! A few comments;

  1. Question/bug(?) about src/openedx_learning/applets/cbe/api.py::get_courses_with_relative_criteria. We have a test that asserts that we'll tie break to the lower 'id'/'tag_id' value if there's a conflict. Is this because we want the 'first' or 'oldest' value? While database IDs like this are sequentially generated, it makes me concerned to tie business logic to that (usually very valid) assumption, since it's not necessarily guaranteed to be an 'earlier' value, it simply happens to be that way. If we want to satisfy this sort of tie-breaking logic to whichever value came first, is there a 'create_date' or something similar we can use that is concretely tied to determining if it is sequentially earlier? e.g. Sort by a creation date rather than the id?

  2. AI Possible Bug: The Codex review is worried about api.py:145-156, api.py:225-227 that we process this in accordance to the course id related to the criterion object, rather than comparing the actual in-database retrieved course object itself most up-to-date data about the course. The Codex analysis states that there's no DB constraint that ties the course to the criterion so it could be stale data (e.g. the course might have changed, been deleted, etc.). Admittedly this seems incorrect to me to think there isn't a DB Foreign Key constraint, so can you check for this, and if the FK constraint is not there, does it make sense to pull in the course so that we can de-conflict with the most accurate course data?

  3. Bug — concurrent requests can bypass the invariant: api.py:168-175, api.py:229 Codex points out that this transaction to save the criterion to the DB is not necessarily atomic, so two transactions could run concurrently and theoretically insert criterion that are conflicted at the time of write but not at the time of checking. The AI suggests doing something like the following to address the issue, though I defer to human judgement:

Remediation Suggestion from Codex:

Details Lock a shared CourseRun row before checking containment, and keep the lock until the criterion is inserted: with transaction.atomic(): course_run = CourseRun.objects.select_for_update().get(pk=course_run.pk)
# create/resolve the group
_validate_containment(group, course_run)
return CompetencyCriterion.objects.create(...)

Place the lock before _validate_containment()—ideally before group creation—so concurrent requests for the same course serialize. Requests for different courses remain concurrent. Ensure every criterion-creation path uses this transaction.

  1. Bug: AI points out that api.py:209-215, api.py:225-229. tag_object() runs before containment validation. This seems problematic since tag_object() does commit changes that we wouldn't want to commit if the _validate_containment() logic fails? AI Suggests remediating this step by running it in an atomic transaction and then validate before tab_object() is called;

Remediation Suggestion from Codex:

Details Run containment validation before tag_object() and wrap the entire operation in transaction.atomic(): with transaction.atomic(): _validate_containment(group, course)
# Only mutate tags after validation succeeds.
tag_object(...)
return CompetencyCriterion.objects.create(...)

This prevents rejected requests from leaving behind an ObjectTag, including when callers invoke create_competency_criterion() directly.


Raw Codex output:

Details Request changes. I found three behavioral issues. - Bug — course scope is derived from group structure, not the criterion’s object: [api.py:145-156](https://github.com/alezconsultant/openedx-core/blob/723a8d52d944ff6924219f8f179110591f737e0a/src/openedx_learning/applets/cbe/api.py#L145-L156), [api.py:225-227](https://github.com/alezconsultant/openedx-core/blob/723a8d52d944ff6924219f8f179110591f737e0a/src/openedx_learning/applets/cbe/api.py#L225-L227). The ticket requires comparing UsageKey.course_key from each criterion’s object_tag.object_id; this implementation trusts group.parent.course. Since no database constraint ties those values together, stale/imported/direct API data can cause false accepts or rejects. - Bug — concurrent requests can bypass the invariant: [api.py:168-175](https://github.com/alezconsultant/openedx-core/blob/723a8d52d944ff6924219f8f179110591f737e0a/src/openedx_learning/applets/cbe/api.py#L168-L175), [api.py:229](https://github.com/alezconsultant/openedx-core/blob/723a8d52d944ff6924219f8f179110591f737e0a/src/openedx_learning/applets/cbe/api.py#L229). Two simultaneous ancestor/descendant creations can both observe no conflict and then insert successfully. transaction.atomic() alone does not serialize this read-then-write check, and no database constraint prevents the result. - Bug — rejected direct API calls leave tag mutations behind: [api.py:209-215](https://github.com/alezconsultant/openedx-core/blob/723a8d52d944ff6924219f8f179110591f737e0a/src/openedx_learning/applets/cbe/api.py#L209-L215), [api.py:225-229](https://github.com/alezconsultant/openedx-core/blob/723a8d52d944ff6924219f8f179110591f737e0a/src/openedx_learning/applets/cbe/api.py#L225-L229). tag_object() runs before containment validation. The endpoint’s outer transaction rolls this back, but the exported create_competency_criterion() function is not itself atomic, so direct callers can receive a validation error while the ObjectTag persists.

Makefile now assumes an already-synced/activated local env; uv run
stays in CI workflow steps only. See openedx/ccx-keys#190 (review
comment r4097501942).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ernize-python-tooling

# Conflicts:
#	requirements/base.txt
#	requirements/ci.txt
#	requirements/dev.txt
#	requirements/doc.txt
#	requirements/quality.txt
#	requirements/test.txt
@alezconsultant
alezconsultant force-pushed the alezconsultant/666-competency-hierarchy-dominance branch from 723a8d5 to e6a548f Compare September 28, 2026 16:07
@alezconsultant

alezconsultant commented Sep 28, 2026 •

Copy link
Copy Markdown
Owner Author

@tbain Thanks for feedback.

  1. Right now timestamp field exists on Tag, and "lowest id" is the ticket's suggestion for get_courses_with_relative_criteria , chosen for determinism, not chronological accuracy, it doesn't affects the accept/reject decision.
  2. CompetencyCriteriaGroup.course has an FK, but add in create_competency_criterion() check that rejects a mismatch between criterion and course
  3. fixed with your suggested approach select_for_update() on the target CourseRun before the containment check.
  4. fixed validation now runs before tag_object() both wrapped in one transaction.atomic().

@tbain tbain left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this looks good to me, and Codex agrees, for the most part. I think it's chasing an erroneous issue with the parent_d checking (the #2 issue noted). I feel like if that is a proper FK relationship up to the parent course, then that means that the concern the AI has (as far as I can determine) is erroneous, since the FK relationship would enforce a valid relationship up to a valid parent course.

Approved!

irfanuddinahmad and others added 2 commits September 30, 2026 15:03
… published deps

The last real release (1.4.0) published Django, edx-drf-extensions, and
edx-organizations with no version bounds at all, since pre-migration
setup.py read requirements/base.in (unpinned) rather than base.txt or
constraints.txt. The pyproject.toml dependencies list introduced by the
uv migration added bounds that never existed in published metadata --
most importantly Django<6.0, which would block openedx-platform (or any
other consumer) from resolving Django 6 until openedx-core cuts a new
release.

The edx-drf-extensions>=10.7.0 lower bound also didn't match history: it
came from a stale requirements/base.txt snapshot on this branch at the
time pyproject.toml was authored. The actual last-released pin (v1.4.0
tag) was 10.8.0, and the latest pre-migration pin on main (before
requirements/base.txt was deleted by the migration) was 10.9.0. Neither
matches 10.7.0, and none of these pins were ever part of published
Requires-Dist in the first place.

Restore all three to unbounded, matching PyPI's published metadata for
1.4.0. Django<6.0 is still enforced for this repo's own lockfile/test
resolution via [tool.uv].constraint-dependencies, which only affects
this repo and does not propagate to consumers.

uv.lock regenerated; resolved package set is unchanged (specifiers only).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
tox.ini's envlist listed a lint-imports entry with no matching
[testenv:lint-imports] section, so it silently fell back to the generic
[testenv] and just re-ran pytest instead of linting imports. This also
meant Makefile's `make quality` (which delegates to `tox -e quality`)
never actually ran import-linter locally.

Delete the bogus envlist entry and instead run `lint-imports` as a step
in [testenv:quality], right after isort. import-linter is already pulled
in via the quality dependency-group (through test -> test-base), so no
new dependency is needed.

.github/workflows/lint-imports.yml is intentionally left in place: it is
currently a required branch-protection status check on main. Removing it
now would leave that required check permanently stuck at "Expected --
Waiting for status" on this PR's head commit, deadlocking the PR. Per
feanil, that workflow should only be removed once quality's lint-imports
step is confirmed working and he lifts the branch-protection requirement
on "Lint Python Imports" (since tox -e quality is already a required
check and will cover the same ground).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
irfanuddinahmad and others added 5 commits October 1, 2026 01:00
- ci.yml: added --locked to uv sync/uv run tox steps. Verified
  uv sync --locked --group ci passes cleanly.
- pyproject.toml: the comment next to the unbounded Django/etc.
  dependencies pointed at [tool.uv].constraint-dependencies as the
  place to add a repo-specific bound -- that block is tool-generated
  output (edx_lint write_uv_constraints), not an editing target.
  Corrected to point at [tool.edx_lint].uv_constraints instead, and
  noted Django<6.0 is already covered globally via edx-lint's
  common_constraints.txt (confirmed directly), not something this
  repo added itself.

Per feanil's review on openedx#704: discussion_r4147327735, r4147327755.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…odernize-python-tooling

feat: modernize Python tooling to uv + pyproject.toml + semantic-release
lint-imports already runs as part of the quality tox env in ci.yml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…#800)

Add the CBE criteria models: CompetencyRuleProfile, CompetencyCriteriaGroup
and CompetencyCriterion (the criteria tree's AND/OR node and leaf), the
rule payload contract and its validation, and
CompetencyTaxonomy.taxonomy_overrides_org. Delete behavior cascades from
tags, groups and object tags. Register django-simple-history, layer
openedx_catalog beside openedx_content in the import contracts, and record
the scope_code and on_delete decisions in ADR-0002.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Every tag now always carries an external identifier (the Competency ID shown
on the Competency Management page), whether an institution assigns one or the
system generates it from the tag's name. Previously a tag only got an
external_id via file import; one created through the taxonomy editor or the
tagging API had none at all.

Tag.save() generates a candidate from the tag's value when external_id is
empty, resolving collisions with a casefold-based per-taxonomy uniqueness
check and a numeric suffix. Taxonomy.add_tag() rejects a caller-supplied
duplicate (exact or case-different) with ValueError instead of letting it
reach the database as an IntegrityError.

A new migration backfills every existing null or empty-string external_id, in
capped in-memory and SQL batches, without overwriting institution-assigned
ones, then makes the column NOT NULL; rollback only loosens that constraint
and leaves generated data in place. A stale pre-upgrade export whose id
collides with another tag's real external_id still fails safely on
re-import, tripping a co-triggered rename action's existing duplicate-value
check; this behavior and its narrower residual limitation are documented in
ADR 0012.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
feanil and others added 23 commits October 1, 2026 14:34
…emove-lint-imports-workflow

build: remove redundant lint-imports workflow
Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 9.0.0 to 10.2.0.
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](astral-sh/setup-uv@c771a70...c18668a)

---
updated-dependencies:
- dependency-name: astral-sh/setup-uv
  dependency-version: 10.2.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Bumps [python-semantic-release/python-semantic-release](https://github.com/python-semantic-release/python-semantic-release) from 10.6.2 to 10.7.0.
- [Release notes](https://github.com/python-semantic-release/python-semantic-release/releases)
- [Changelog](https://github.com/python-semantic-release/python-semantic-release/blob/master/CHANGELOG.rst)
- [Commits](python-semantic-release/python-semantic-release@9a026e9...b700dbe)

---
updated-dependencies:
- dependency-name: python-semantic-release/python-semantic-release
  dependency-version: 10.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Add two of openedx#642's four learner-status models. CompetencyMasteryStatus
is the mastery-rank lookup table, seeded with ids 10, 20 and 30 so the
database can compare ranks directly. StudentCompetencyStatus holds one
row per learner and competency tag, updated in place, and a check
constraint keeps AttemptedNotDemonstrated out of it. Deleting a user
deletes their status rows, and a tag with status rows cannot be
deleted.

Part of openedx#642.

Co-authored-by: Jesper Hodge <jhodge@unicon.net>
…/python-semantic-release/python-semantic-release-10.7.0

build(deps): bump python-semantic-release/python-semantic-release from 10.6.2 to 10.7.0
…/astral-sh/setup-uv-10.2.0

build(deps): bump astral-sh/setup-uv from 9.0.0 to 10.2.0
Add the last two of openedx#642's four learner-status models.
StudentCompetencyCriterionStatus holds a learner's mastery status for
one leaf criterion, and StudentCompetencyCriteriaGroupStatus holds it
for one criteria group. Each holds one row per learner and criterion,
or per learner and group, updated in place, and accepts all three
status values. Deleting a user deletes their status rows, and a
criterion, group, object tag, tag or taxonomy with status rows beneath
it cannot be deleted.

Closes openedx#642.

Co-authored-by: Jesper Hodge <jhodge@unicon.net>
A taxonomy re-import row can change an existing tag's external_id in place by
setting a previous_id column alongside its new id: when previous_id matches
an existing tag's external_id and the row's id differs, the import renames
that tag instead of deleting it and creating a new one, preserving its
primary key, its associations, and its import/export identity (see ADR 0010).

Renames within one import are order-independent, including a swap or longer
cycle where two or more tags trade external_id values in the same file: any
tag whose current external_id is another row's rename target is staged
through a temporary placeholder before the row claiming that id executes,
since (taxonomy, external_id) is a per-statement unique constraint. A rename
or parent update resolves its target tag by primary key rather than
re-resolving by external_id at execute time, so a tag already renamed earlier
in the same import is still matched correctly. This staging covers
external_id only; a row that also tries to take the other tag's current
value in the same swap is still rejected, since value carries the same
per-taxonomy uniqueness without the same staging treatment.

The import plan rejects, before any action executes: two or more rows
sharing the same previous_id; two or more rows resolving to the same final
external_id; a parent_id referencing a parent's stale pre-rename external_id;
and a rename targeting a tag a replace-mode delete sweep is removing in the
same import. Conversely, a replace-mode delete sweep now correctly treats a
rename row's id as the tag's desired new external_id, not proof that the tag
currently holding that id should survive, so a rename can reuse an
external_id the same import's delete sweep is freeing up.

Test coverage runs the rename round-trip, replace-mode, and idempotent
re-import scenarios through the CSV parser as well as JSON, matching the
path Studio's import wizard actually uses.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…edx#853)

django-simple-history bakes the SIMPLE_HISTORY_DATE_INDEX setting's value
into the db_index it generates for every HistoricalRecords() model's
history_date field. This library's test settings never set it, so its
committed migrations recorded db_index=True (the django-simple-history
default) for CompetencyCriteriaGroup, CompetencyCriterion, and
CompetencyRuleProfile's historical tables. openedx-platform, the primary
consumer these models are built for, sets SIMPLE_HISTORY_DATE_INDEX = False
globally in openedx/envs/common.py, so installing this library there makes
the live model state disagree with the shipped migrations: Django detects a
missing migration for history_date on every one of these three tables the
moment openedx-platform's own test suite loads them, failing its
MigrationTests.test_migrations_are_in_sync check.

Sets the same value in test_settings.py and dev.py so this library's own
migrations match what its actual consumer produces, and adds the migration
that `makemigrations` generates as a result.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…terion (openedx#716)

Adds archived = models.BooleanField(default=False) to both models, in one
migration, per ADR 0002 Decision 7's archive-only retirement rule. No
archived_at companion field: django-simple-history (ADR 0003) already
tracks both models, covering the "when" without a separate timestamp.

Both models' existing `.. no_pii:` class-level docstring annotation
already covers the new field, matching CompetencyRuleProfile.archived's
existing non-PII precedent; no separate registry change needed.

This ticket makes no behavior changes: nothing here sets, reads, or
filters on the field. That is downstream tickets' scope (openedx#674, openedx#675, openedx#681).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…edx#861)

openedx-platform's own import-linter config already treats openedx_catalog
(and openedx_learning) as an isolated app, reachable from outside only
through its api/models_api/data/tests modules, which is how a foreign-key
import into openedx_catalog.models (instead of models_api) got caught on
the platform's CI and not here: this repo's own .importlinter only layers
top-level packages, it says nothing about which modules within a package
may be reached from outside it.

Adds an equivalent "isolated_apps" contract type, scoped to openedx_catalog
only for now; extending it to openedx_content, openedx_learning, or
openedx_tagging would surface pre-existing violations (confirmed by testing
the broader scope and reverting), out of scope for this change.

IsolatedAppsContract itself is ported verbatim from openedx-platform's
openedx.testing.importlinter.isolated_apps_contract (this library can't
depend on openedx-platform), with one change: self.isolated_apps and
self.allowed_modules are copied through list() before iterating, working
around a pylint false positive (E1133) caused by pylint not understanding
that Contract._populate_fields() replaces these class-level Field
descriptors with their parsed list values at the instance level.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 10.2.0 to 10.3.0.
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](astral-sh/setup-uv@c18668a...1c37ad0)

---
updated-dependencies:
- dependency-name: astral-sh/setup-uv
  dependency-version: 10.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 8.0.1 to 8.0.2.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](actions/download-artifact@3e5f45b...9000827)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: 8.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 7.0.1 to 7.0.2.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](actions/upload-artifact@043fb46...cf430e0)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: 7.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Adds GET api/cbe/v1/rule_profiles/, a paginated, read-only collection
of every non-archived CompetencyRuleProfile, and the REST scaffolding
that later CBE endpoints will share: the applets/cbe/rest_api package,
openedx_learning/urls.py, and openedx_learning/rules.py. Each row
reports id, scope_type (derived from the scope columns), rule_type,
rule_payload and archived; scope_code and the scope foreign keys stay
internal. The endpoint is UNSTABLE until the rest of the rule profile
API lands.

Reading is gated on a new openedx_learning.view_competencyruleprofile
permission, registered as rules.is_authenticated &
can_view_competency_rule_profile. Any signed-in user may list profiles
and read the system default. A taxonomy-scoped profile is as visible
as its taxonomy, checked through has_perm("oel_tagging.view_taxonomy")
so that openedx-platform's org-aware override applies. Course- and
organization-scoped profiles are refused to everyone for now (openedx#645).
The list does not yet filter rows per profile; that has to change when
the first scoped profile is created.

A consuming project includes openedx_learning.urls under a prefix of
its choice (openedx-platform uses api/) and needs
rules.apps.AutodiscoverRulesConfig in INSTALLED_APPS and
rules.permissions.ObjectPermissionBackend in AUTHENTICATION_BACKENDS,
or every caller is refused. See src/openedx_learning/README.rst.

Closes openedx#773.

Co-authored-by: javoconsultant <jmeliev_consultant@flex.unicon.net>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs: ADR proposals for improved static/media asset handling

* docs: fix forward reference to ADR that doesn't exist yet

* docs: minor typos

* docs: blob_... -> media_file_namespace, dedup at org level, link other ADR

* docs: forward ref to next ADR

* docs: mention the future option for flex packages that combine runs

* docs: edit to clarify/simplify some of the rejected alternatives

* docs: mention idea of using collections to separate runs within LP

* docs: update since catalog -> content layering is pending decision

* docs: CourseContent holds the relationship

* docs: typo

* docs: clarifications around using org code as media file namespace

* docs: guiding principle: no 1:1 assumptions between contexts and packages

* docs: clarify wording in ADR 0012

Co-authored-by: Kyle D McCormick <kyle@axim.org>

* docs: suggestions from review

---------

Co-authored-by: Kyle D McCormick <kyle@axim.org>
…/actions/upload-artifact-7.0.2

build(deps): bump actions/upload-artifact from 7.0.1 to 7.0.2
…/astral-sh/setup-uv-10.3.0

build(deps): bump astral-sh/setup-uv from 10.2.0 to 10.3.0
…/actions/download-artifact-8.0.2

build(deps): bump actions/download-artifact from 8.0.1 to 8.0.2
Add POST /api/cbe/v1/competencies/<int:tag_id>/criteria/, associating a gradeable
subsection with a competency. When group_id is supplied, the CompetencyCriterion is
attached to that existing leaf CompetencyCriteriaGroup. When it is omitted, the root and
course-level groups for this competency and course are reused or created, and a new leaf
is always created under them.

Add to applets/cbe/api.py, all marked UNSTABLE: resolve_competency_tag(),
resolve_supplied_leaf_group(), create_competency_criterion(), and
associate_competency_criterion() as the single entry point the view calls. Group creation
is the private _create_leaf_group(), so groups are only ever created behind the
duplicate-association check. The API raises Django ValidationError and model DoesNotExist
exceptions; the view translates them into 400 and 404 responses, and a 404 carries the
exception's message, so a missing tag, a tag outside a competency taxonomy, and a missing
group can be told apart.

Group creation and the duplicate-association check run inside one transaction that first
locks the competency's Tag row with select_for_update(), so concurrent requests for the
same competency are serialized. MySQL has no partial unique indexes, so the one-root-per-tag
rule cannot be backed by a constraint and relies on that lock. Add migration 0015: a
UniqueConstraint on CompetencyCriteriaGroup (tag, course, parent) that backstops
course-level group creation. The root and course-level lookups match archived rows
deliberately, so an archived container is never replaced by a second row in its slot.

When a request supplies no rule fields, the criterion is assigned the live rule profile
scoped to the competency's taxonomy if one exists, and the seeded system-default profile
otherwise. It is never left with all three rule fields null, because CompetencyCriterion's
profile_xor_override_check constraint forbids that. A request that supplies a profile
together with an override, only one of the two overrides, or an archived or unknown profile
is rejected with a 400 before anything is written.

Related to openedx#665

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 CompetencyCriterion in the same course. Siblings, and a
repeat criterion for the same tag in the same course, stay allowed.

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), a public function
mapping each course already blocked by one of the given tags to the lowest-id
conflicting tag. Add _validate_containment(group, course), called from
create_competency_criterion() before the object is tagged.

create_competency_criterion() now runs in one transaction that locks the object's
CourseRun row, so two concurrent creates in the same course cannot both pass the
containment check. It also rejects an object_id whose course differs from the group's
course.

Related to openedx#666

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@alezconsultant
alezconsultant force-pushed the alezconsultant/666-competency-hierarchy-dominance branch from e6a548f to 88460a5 Compare October 16, 2026 17:49
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.

10 participants