Repository navigation
feat: enforce competency-hierarchy dominance for criteria - #2
alezconsultant wants to merge 44 commits into
Conversation
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>
…dropped during merge
…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>
… 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
left a comment
There was a problem hiding this comment.
Hello! A few comments;
-
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?
-
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?
-
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.
- 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
723a8d5 to
e6a548f
Compare
|
@tbain Thanks for feedback.
|
There was a problem hiding this comment.
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!
… 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>
- 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>
…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>
e6a548f to
88460a5
Compare
Description
Implements openedx#666: before a
CompetencyCriterionis created, reject it witha 400 if any taxonomy ancestor or descendant of its competency tag already has a
CompetencyCriterionin the same course. This is the containment checkcreate_competency_criterion()reserved a seam for in openedx#665. Siblings, and a repeat criterionfor 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 intojesperhodge/feat--641-competency-criteria-models), since that's where the models and thecreate-criterion endpoint this check attaches to actually live.
Changes
src/openedx_tagging/api.py: addsget_ancestor_tags(tag)andget_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 itsfree-text-taxonomy guard.
src/openedx_learning/applets/cbe/api.py: addsget_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 insertionpoint, replacing the marker comment with a three-line addition (two
assertcalls narrowinggroup.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 taggingfunctions and for
get_courses_with_relative_criteria/_validate_containmentin 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