Skip to content

Alezconsultant/665 criterion endpoint on 846 - #847

Merged
ormsbee merged 1 commit into
openedx:mainfrom
alezconsultant:alezconsultant/665-criterion-endpoint-on-846
Oct 16, 2026
Merged

ormsbee merged 1 commit into
openedx:mainfrom
alezconsultant:alezconsultant/665-criterion-endpoint-on-846

Conversation

@alezconsultant

@alezconsultant alezconsultant commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Description

Adds the create-criterion endpoint, POST /api/cbe/v1/competencies/<int:tag_id>/criteria/.
A course author uses it to associate a gradeable subsection with a competency. When the
request supplies a group_id, the criterion is attached to that existing leaf
CompetencyCriteriaGroup. When it doesn't, the endpoint reuses or creates the root and
course-level groups for this competency and course, and always creates a new leaf under them.

Changes

  • src/openedx_learning/migrations/0015_criteria_group_unique_constraints.py (new): one
    UniqueConstraint on CompetencyCriteriaGroup over (tag, course, parent), so there is at
    most one course-level group per competency, course and parent. It has no condition,
    because MySQL has no partial indexes. A unique index allows repeated NULLs, and course is
    NULL for roots and leaves, so roots and leaves are not constrained by it. The matching
    constraint is in CompetencyCriteriaGroup.Meta in models/criteria.py.
  • src/openedx_learning/applets/cbe/api.py: adds resolve_competency_tag(),
    resolve_supplied_leaf_group(), create_competency_criterion(), and
    associate_competency_criterion(), which is the single entry point the view calls. All of
    them are marked UNSTABLE. They raise Django ValidationError and model DoesNotExist
    exceptions, never HTTP exceptions. Group creation lives in the private
    _create_leaf_group(), so no caller can create groups without going through the
    duplicate-association check.
  • src/openedx_learning/applets/cbe/rest_api/v1/serializers.py: adds
    CompetencyCriterionSerializer. object_id and logic_operator are write-only, because
    neither is a CompetencyCriterion field. The other JSON names match the model attributes
    (group_id, rule_profile_id, object_tag_id). validate_object_id() rejects an
    unparseable usage key with a 400.
  • src/openedx_learning/applets/cbe/rest_api/v1/views.py: adds
    CompetencyCriterionCreateView. It checks oel_tagging.can_tag_object for the tag's taxonomy
    and the object_id, then calls associate_competency_criterion(), translating
    ValidationError into a 400 and DoesNotExist into a 404.
  • src/openedx_learning/applets/cbe/rest_api/v1/urls.py: adds
    competencies/<int:tag_id>/criteria/ as a plain path(), since the URL carries a path
    parameter mid-route.
  • tests/openedx_learning/applets/cbe/test_api.py and test_views.py: unit and DRF tests (see
    Tests).
  • tests/openedx_tagging/test_api.py: test_get_taxonomy looks up 2**31 - 1 instead of 200
    as its missing taxonomy id. MySQL does not reset auto-increment counters between tests, so
    with this PR's extra tests a taxonomy with id 200 can exist by the time test_get_taxonomy
    runs, and CI on MySQL failed on it.

Behavior

  • Concurrency. Group creation and the duplicate-association check run in one transaction
    that first locks the competency's Tag row with select_for_update(). That lock serializes
    concurrent requests for the same competency, and it is the only thing that keeps one root
    per competency, since MySQL can't express that as a constraint. The (tag, course, parent)
    constraint backstops course-level groups.
  • Rule profile. When the request supplies none of rule_profile_id, rule_type_override
    and rule_payload_override, the criterion gets the live rule profile scoped to the
    competency's taxonomy if one exists, and the seeded system-default profile otherwise.
    Course- and organization-scoped profiles are not resolved here, because this endpoint
    operates at taxonomy scope.
  • Rule-field validation. A profile together with an override, only one of the two
    overrides, or an unknown or archived rule_profile_id is a 400 keyed by the offending field,
    returned before anything is written.
  • Duplicates. A second criterion for the same tag and object_id is rejected when it
    targets the same group, or when no group_id is supplied. A different explicit group is
    allowed, because ADR-0002 lets one ObjectTag be referenced by criteria in more than one
    group. The rules are enforced in application code under the tag lock, not by a constraint
    on CompetencyCriterion.
  • Tagging. tag_object() replaces an object's tag list for a taxonomy, so the object's
    existing tags are read first and the competency tag is unioned in. When the taxonomy refuses
    the tag (allow_multiple=False, or the object is at its tag limit), the taxonomy's message
    is returned as a 400 on object_id.
  • Group names. Root and course-level groups are named from the tag value and the course
    title, truncated to the column length. Neither is shown in the UI. Leaf groups are unnamed.
  • Archived groups. The get_or_create() lookups for the root and course-level groups match
    archived rows too, so they never insert a second row into an occupied slot. Rejecting an
    archived course-level group or leaf, and reactivating an archived root, are handled by [BE] Enforce archived in Competency Criteria creation #717.
    Nothing sets archived on groups or criteria yet.

Tests

There is one test per scenario in #665's acceptance criteria. Beyond those, the tests cover:

  • each rule-field rejection
  • the taxonomy-scoped profile lookup, including archived and other-taxonomy profiles
  • a taxonomy that refuses the tag, for both allow_multiple=False and the object tag limit
  • truncated group names
  • unit tests for _create_leaf_group() and resolve_supplied_leaf_group(), including two that
    commit a root or course-level group out of band, standing in for a concurrent request, and
    check that get_or_create() reuses it
  • a rollback test proving that groups created in the transaction are removed when creating
    the criterion fails

Verification

  • pytest tests/openedx_learning/applets/cbe/ tests/openedx_tagging/ --no-cov: 794 passed (223 in the CBE suite).
  • pylint, mypy, pycodestyle, isort --check-only, pydocstyle: clean on every changed
    file.
  • lint-imports: all contracts kept.
  • makemigrations --check: no changes.

Related to #665

🤖 Generated with Claude Code

@openedx-webhooks openedx-webhooks added the open-source-contribution PR author is not from Axim or 2U label Oct 2, 2026
@openedx-webhooks

openedx-webhooks commented Oct 2, 2026 •

Copy link
Copy Markdown

Thanks for the pull request, @alezconsultant!

This repository is currently maintained by @axim-engineering.

Once you've gone through the following steps feel free to tag them in a comment and let them know that your changes are ready for engineering review.

🔘 Get product approval

If you haven't already, check this list to see if your contribution needs to go through the product review process.

  • If it does, you'll need to submit a product proposal for your contribution, and have it reviewed by the Product Working Group.
    • This process (including the steps you'll need to take) is documented here.
  • If it doesn't, simply proceed with the next step.
🔘 Provide context

To help your reviewers and other members of the community understand the purpose and larger context of your changes, feel free to add as much of the following information to the PR description as you can:

  • Dependencies

    This PR must be merged before / after / at the same time as ...

  • Blockers

    This PR is waiting for OEP-1234 to be accepted.

  • Timeline information

    This PR must be merged by XX date because ...

  • Partner information

    This is for a course on edx.org.

  • Supporting documentation
  • Relevant Open edX discussion forum threads
🔘 Get a green build

If one or more checks are failing, continue working on your changes until this is no longer the case and your build turns green.

Details
Where can I find more information?

If you'd like to get more details on all aspects of the review process for open source pull requests (OSPRs), check out the following resources:

When can I expect my changes to be merged?

Our goal is to get community contributions seen and reviewed as efficiently as possible.

However, the amount of time that it takes to review and merge a PR can vary significantly based on factors such as:

  • The size and impact of the changes that it introduces
  • The need for product review
  • Maintenance status of the parent repository

💡 As a result it may take up to several weeks or months to complete a review and merge your PR.

@github-project-automation github-project-automation Bot moved this to Needs Triage in Contributions Oct 2, 2026
@alezconsultant
alezconsultant marked this pull request as draft October 2, 2026 16:04
@alezconsultant
alezconsultant force-pushed the alezconsultant/665-criterion-endpoint-on-846 branch from 2c888e6 to 2ca89b7 Compare October 9, 2026 19:20
ufedaseyeuconsultant added a commit to ufedaseyeuconsultant/openedx-core that referenced this pull request Oct 12, 2026
The help_text on CompetencyCriteriaGroup.archived and
CompetencyCriterion.archived named "the criteria-tree read endpoint"
(openedx#681, not yet built) as if it were the only consumer, when openedx#674 and
openedx#675 are what actually set the flag and openedx#847's create_leaf_group()
will need to read it too. Reworded both to match
CompetencyRuleProfile.archived's existing convention (ADR-0002
Decision 2): describe what archiving means for the row itself, not
what one downstream endpoint does with it.

Regenerates the migration (Django bakes help_text into it) with a
descriptive name instead of the auto-generated "_and_more" one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

@mgwozdz-unicon mgwozdz-unicon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I reviewed and approved here: jesperhodge#14

@ormsbee ormsbee assigned ormsbee and unassigned ormsbee Oct 13, 2026
@ormsbee
ormsbee self-requested a review October 13, 2026 15:04
@ormsbee ormsbee moved this from Needs Triage to In Eng Review in Contributions Oct 13, 2026
@ormsbee
ormsbee marked this pull request as ready for review October 13, 2026 15:05
@alezconsultant
alezconsultant force-pushed the alezconsultant/665-criterion-endpoint-on-846 branch 4 times, most recently from 45c0162 to dd837ca Compare October 13, 2026 16:09

@ormsbee ormsbee left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Apologies for the delay on this. It took me a while to try to wrap my head around this, and I'm honestly still not sure I totally grok everything that's going on.

Claude ran the CBE suite, the quality checks, and makemigrations --check on this head for me (all clean, and CI is green on MySQL), and then sent a handful of requests the tests don't send, which is where most of what follows comes from.

The shape is right and the tests are thorough for the inputs they cover. What I'd like fixed before merge is mostly about input the tests don't send, plus one design question that this PR makes concrete:

  1. Please validate the rule fields before the row reaches the database: two field combinations 500 on the check constraint, an archived profile is accepted for a new association, and a taxonomy with allow_multiple=False 500s (inline on serializers.py and api.py).
  2. The generated root and course-level group names can be longer than the column, which is a DataError on MySQL in strict mode (inline on api.py).
  3. #665's taxonomy-scoped profile scenario isn't implemented; the criterion always gets the system default (inline on api.py). Please implement it or write down that it's deferred; I'd rather have the lookup.
  4. Please keep Http404 out of api.py (inline).
  5. [Question] How archived container groups are meant to interact with get_or_create() and the new unique constraint (inline on criteria.py). #857 has merged since I started this review, so archived is on both models now, and once this is rebased, this is the code that ignores it. I don't need code for this here, but I want the decision written down before this merges, because the fix if we get it wrong is another scope_code-style column and a data migration on a table that will have grown by then.

There are also some smaller, non-blocking issues in the inline comments.

Logistics: #846 and #857 have both merged, so please rebase just this commit onto main. Claude did that for me and it applies with no conflicts; the one thing that breaks is the migration number, since #857 took 0014. Please rename it to 0015_criteria_group_unique_constraints and point its dependency at 0014_add_archived_to_criteria_group_and_criterion (a renumber, not a merge migration). With just that change, makemigrations --check is clean and the CBE suite passes on the rebased tree (212 tests).

Thank you.


Claude drafted this review and ran the test suite, quality checks, makemigrations --check, and the probe requests quoted inline on 2ca89b7 for me, and then did the trial rebase onto main once #846 and #857 had merged.

Comment thread src/openedx_learning/applets/cbe/api.py Outdated
existing_values = [existing_tag.value for existing_tag in get_object_tags(object_id, taxonomy_id=tag.taxonomy_id)]
if tag.value not in existing_values:
existing_values.append(tag.value)
tag_object(object_id, tag.taxonomy, existing_values)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

tag_object() raises ValueError when the taxonomy has allow_multiple=False and this is the second competency on the object, and again when the object is already at OBJECT_MAX_TAGS (_get_current_tags() and _check_new_tag_count()). Claude hit the first one and it came back as a 500. Please catch it here and raise a ValidationError on object_id, so the taxonomy's message reaches the caller.

Comment thread src/openedx_learning/applets/cbe/api.py Outdated
root, _ = CompetencyCriteriaGroup.objects.get_or_create(
tag=tag,
parent=None,
defaults={"name": f"{tag.value} (root)"},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Tag.value is max_length=500 and CourseRun.title is 255, but CompetencyCriteriaGroup.name is 255, so both of these names can overflow. Claude created a 300-character tag and a 255-character course title, and create_leaf_group() stored a 307-character root name and a 558-character course-level name (SQLite doesn't enforce the length). On MySQL with STRICT_TRANS_TABLES, which Tutor sets and Django expects, that's a DataError and a 500; without strict mode, MySQL truncates silently. Since this is a real outlier and #665 says these two groups are never shown in the UI, I'd just truncate name to fit and make a note of it in the docstring.

Related question: is the leaf meant to have a name? #665 says "named from the course's display name" for the leaf, and you've put that on the course-level group instead, which seems more sensible to me. But whoever is building #672 should know which it is.

Comment thread src/openedx_learning/applets/cbe/api.py Outdated
object_tag = ObjectTag.objects.get(object_id=object_id, taxonomy_id=tag.taxonomy_id, tag_id=tag.id)

if rule_profile_id is None and rule_type_override is None and rule_payload_override is None:
rule_profile_id = CompetencyRuleProfile.objects.get(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This always assigns the system default, but #665's acceptance criteria include "A taxonomy-scoped rule profile is assigned ahead of the system default", and ADR-0002 Decision 4's table says the taxonomy profile wins when no course or organization profile exists. Claude created a live taxonomy-scoped profile for the tag's taxonomy and POSTed with no rule fields: the criterion came back with the system default. The ADR's MVP note says every criterion gets the default because no authoring screen supplies a context, but this endpoint does know the taxonomy (it's tag.taxonomy), and taxonomy-scoped profiles can be created today through the admin, which is what test_api.py's own fixtures do. Since the ADR also says the FK is never re-resolved at read time, a criterion created with the wrong profile stays wrong until something edits it.

Please either look up the live taxonomy-scoped profile first and fall back to the default, or state in the commit message that profile resolution beyond the default is deferred and get #665 updated to match. I'd rather have the lookup; it's a few lines. (I wouldn't go as far as course-scoped profiles here: the ADR says a criterion is assigned using the scope the creating screen operates in, and this one is taxonomy-scoped.)

Comment thread src/openedx_learning/applets/cbe/api.py Outdated
Raises Http404 if no such Tag exists, or if it isn't on a CompetencyTaxonomy. Shared by
the view and :func:`associate_competency_criterion` so both agree on what counts as valid.
"""
tag = get_object_or_404(Tag, pk=tag_id)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please keep HTTP out of api.py. The only other api.py in this repo that imports from django.http is the components one, where the function's whole job is to build the response the reverse proxy serves, 404s included; nothing else here raises Http404 from a lookup. And this module is going to be called from places that aren't views (the containment check in #666, event handlers, management commands). Raise Tag.DoesNotExist and CompetencyCriteriaGroup.DoesNotExist here (or a small CBE exception, if you'd rather callers not depend on model classes), and have the view turn those into Http404. #665 suggested get_object_or_404, but that was about the view not having an exception handler, and the view is where the translation belongs. Same note for resolve_supplied_leaf_group().

"""
import pytest
import rules
from django.contrib.auth.models import User as UserType # pylint: disable=imported-auth-user

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

UserType in openedx_tagging.rules is the User | AnonymousUser alias, so aliasing the concrete model to the same name is confusing when the two files are read side by side. #846 dropped this alias from conftest.py and test_rules.py before it merged, so after the rebase this would be the only one left. get_user_model() is fine, or just drop the annotation.


expected = [default_rule_profile, taxonomy_scoped, organization_scoped]
assert list(get_competency_rule_profiles()) == expected
assert list(get_competency_rule_profiles()) == expected

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
assert list(get_competency_rule_profiles()) == expected

Is there a reason this was duplicated?

assert CompetencyCriteriaGroup.objects.filter(tag=tag, course=course_run).count() == 1


def test_create_leaf_group_locks_the_tag_row_before_creating_groups(tag: Tag, course_run: CourseRun) -> None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: This test and test_associate_competency_criterion_locks_the_tag_row_before_the_duplicate_check pin the implementation (that select_for_update() was called, and in what order) rather than any behavior, and they'll have to be rewritten by whoever changes the locking, including if you take my suggestion on _reject_duplicate_criterion(). I'd drop them; the pre-committed-row tests above are the useful ones.

Comment on lines +52 to +57
# A different tag from `childless`'s: the one-root-per-tag constraint allows only one root group
# per tag, and this scenario is about child count, not about tag reuse.
other_tag = Tag.objects.create(taxonomy=tag.taxonomy, value=f"{tag.value} (other)")
parent = CompetencyCriteriaGroup.objects.create(tag=other_tag, logic_operator=logic_operator)
CompetencyCriteriaGroup.objects.create(tag=other_tag, parent=parent)
CompetencyCriteriaGroup.objects.create(tag=other_tag, parent=parent)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This change isn't needed, and the comment is wrong: there's no one-root-per-tag constraint in this PR (the commit message explains why MySQL can't have one), and NULLs are distinct in a unique index, so two roots for the same tag still save. Claude ran the pre-commit version of this file against this branch and it passes. Please revert the hunk, and fix the sentence in the PR description that says the constraint rejects this.

Suggested change
# A different tag from `childless`'s: the one-root-per-tag constraint allows only one root group
# per tag, and this scenario is about child count, not about tag reuse.
other_tag = Tag.objects.create(taxonomy=tag.taxonomy, value=f"{tag.value} (other)")
parent = CompetencyCriteriaGroup.objects.create(tag=other_tag, logic_operator=logic_operator)
CompetencyCriteriaGroup.objects.create(tag=other_tag, parent=parent)
CompetencyCriteriaGroup.objects.create(tag=other_tag, parent=parent)
parent = CompetencyCriteriaGroup.objects.create(tag=tag, logic_operator=logic_operator)
CompetencyCriteriaGroup.objects.create(tag=tag, parent=parent)
CompetencyCriteriaGroup.objects.create(tag=tag, parent=parent)

Comment thread src/openedx_learning/applets/cbe/api.py Outdated
Must run inside a transaction holding the tag row lock.
"""
assert tag.taxonomy_id is not None # narrowing for mypy; callers resolve competency tags only
# Locking reads, so a duplicate committed by a concurrent request is seen under MySQL REPEATABLE READ.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We should never be running MySQL with REPEATABLE_READ as the isolation level. Django has defaulted to READ COMMITTED with MySQL since Django 2.0. There isn't any good reason to override that behavior, given that the framework assumes READ COMMITTED.

@alezconsultant

alezconsultant commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor Author

@ormsbee Thanks for the review. Everything is addressed in the new commit, branch was rebased. PR description updated. Several of these decisions were made before the archived logic was designed.
What I've done:

  • Rule fields are now validated: invalid combinations return a 40, archived profiles are rejected
  • A tag_object refusal (allow_multiple=False or the tag limit) returns a 400 on object_id
  • Root and course-level group names are truncated to fit the column limit
  • The taxonomy-scoped rule profile is used ahead of the system default
  • api.py no longer raises Http404
  • The select_for_update calls are dropped
  • validate_object_id is added to the serializer
  • _create_leaf_group is now private, without atomic
  • rule_profile_id is used everywhere
  • UNSTABLE markers are added to the new public functions
  • The permission fixture is a yield fixture that restores the original predicate
  • The UserType alias, the duplicate assert, and the lock-pinning tests are removed
  • The test_criteria_group.py hunk is reverted
  • The branch is rebased onto main, with the migration renamed to 0015_criteria_group_unique_constraints

Here are the answers and proposals to implementation:

1. Archived container groups

How is this supposed to interact with archived? … either container groups are never archived (only leaves and criteria are, and the containers get reused), or they can be and there's a column that makes that work.

Container groups can be archived. #674, #675 and #717 tickets suggest that we should archive every level. #674's "must run all the way to the root group, not just the immediate parent, stopping only once a level still has active content of its own". I think option with scope_code field is better choice, and we can use that field in get_or_create function to filter out archived and create new records.

2. Leaf name

Related question: is the leaf meant to have a name? #665 says "named from the course's display name" for the leaf, and you've put that on the course-level group instead, which seems more sensible to me. But whoever is building #672 should know which it is.

A course can have multiple leaf, so a name based on the course would be duplicated for leafs, and a name based on subsections would mean handling leaves with several subsections and edge cases after rename. I think it's better to update the #665 AC to match the current implementation.

3. Unique constraint on (object_tag, group)

And should CompetencyCriterion have a unique constraint on (object_tag, group)?
The code already check with the tag lock. It would also conflict with #717 ticket, which stops archived criteria from blocking a "re-linking": re-adding the same content to the same leaf by group_id would hit the archived row and fail as a 500, since MySQL has no partial indexes.

@ormsbee ormsbee left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for the quick turnaround on all of this. Claude re-ran the CBE suite and makemigrations --check for me (223 tests, both clean; CI is green on MySQL) and sent the archived-group requests I quote in the criteria.py thread. Everything from the last round is addressed.

I have a couple of minor requests, but please feel free to squash the commits after they're taken care of, and rewrite the commit message to take into account the various adjustments that have been made.

A course can have multiple leaf, so a name based on the course would be duplicated for leafs, and a name based on subsections would mean handling leaves with several subsections and edge cases after rename. I think it's better to update the #665 AC to match the current implementation.

I think that's fine, though I defer to @mgwozdz-unicon on this. Please update #665's acceptance criteria (or get them updated) so that #672 builds against what's actually there.

Thank you.


Claude drafted this review and ran the test suite, makemigrations --check, and the probe requests quoted in the criteria.py thread on dd5f40e for me.

tag/course pair can hold more than one leaf, one per criterion. The root and course-level
group names are truncated to fit the name column; they are never shown in the UI.

Must run inside a transaction holding the tag row lock.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please add a sentence here that both get_or_create()s match archived rows deliberately and that #717 decides what happens to them.

try:
tag = resolve_competency_tag(self.kwargs["tag_id"])
except Tag.DoesNotExist as exc:
raise NotFound() from exc

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: NotFound(str(exc)) here and on line 103, so a client can tell "no such tag" from "that tag isn't a competency" from "no such group". All three come back as {"detail": "Not found."} today, and resolve_competency_tag() already composes the useful message.

Comment thread tests/openedx_tagging/test_api.py Resolved
# Backstops create_leaf_group()'s get_or_create() for course-level groups. It has no
# `condition` because MySQL has no partial indexes: a unique index allows repeated
# NULLs, and `course` is NULL for roots and leaves.
models.UniqueConstraint(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Okay, please create a follow-up issue for the scope_code. I don't want to drag out this PR any longer, but I also don't want us to rely on locking the Tag to ensure data integrity in the long term. Thank you.

@mgwozdz-unicon

Copy link
Copy Markdown
Contributor

Displaying the group name is out of scope for MVP so I don't think this will impact #672 but I am working with Tammie to get #665 updated soon.

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>
@alezconsultant
alezconsultant force-pushed the alezconsultant/665-criterion-endpoint-on-846 branch from 6034b36 to c91b6fa Compare October 15, 2026 18:21
@alezconsultant

Copy link
Copy Markdown
Contributor Author

@ormsbee #872 new ticket for scope_code everything else fixed, commits squashed.

@mgwozdz-unicon

Copy link
Copy Markdown
Contributor

#665 is updated.

@ormsbee
ormsbee merged commit 8831d82 into openedx:main Oct 16, 2026
7 checks passed
@github-project-automation github-project-automation Bot moved this from In Eng Review to Done in Contributions Oct 16, 2026
ormsbee pushed a commit that referenced this pull request Oct 22, 2026
Add PATCH /api/cbe/v1/criteria-groups/<group_id>/criteria/bulk-update/
and bulk_update_competency_criteria(), which apply one rule source
(a rule profile, or a pair of override values) to a named batch of
CompetencyCriterion rows in a leaf group, atomically, with one history
row per changed criterion (#759).

Deviations from #759:

- The Python keyword is rule_profile_id rather than
  competency_rule_profile_id, matching the names #847 settled on for
  associate_competency_criterion() and the serializer.
- The applicable profile is resolved once per batch from the group's
  taxonomy, through a helper shared with create_competency_criterion(),
  rather than per criterion via #679's helper: #679 is out of scope,
  and every criterion in a leaf shares the group's tag.
- A batch under an archived leaf, course-level, or root group is
  refused with 409, not just one naming an archived criterion, because
  archiving a group does not archive its criteria. That check runs
  before the criterion lookup, so it takes precedence over a 404.

Follow-ups: #898 for dropping the foreign key existence queries that
CompetencyCriterion.save() issues through full_clean(); #723 for the
learner-status warning that will sit in front of this endpoint.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-authored-by: javoconsultant <jmeliev_consultant@flex.unicon.net>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

open-source-contribution PR author is not from Axim or 2U

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

4 participants