From f18ef9348901a4ef347e9b711c6e6f7a9478cbc9 Mon Sep 17 00:00:00 2001 From: ufedaseyeuconsultant Date: Thu, 22 Oct 2026 21:50:33 +0300 Subject: [PATCH 1/4] feat: reject new criteria under archived competency groups (#717) Creating a competency criterion honors CompetencyCriteriaGroup.archived and CompetencyCriterion.archived, per ADR 0002 Decision 7: - A supplied group_id naming an archived leaf is rejected with a 400 keyed group_id. - On the derive path, an archived course-level group for the object's course is rejected with a 400 keyed object_id. No sibling group is created in its place. - On the derive path, an archived root is reactivated in place, so archiving a competency's last course leaves the competency open to new criteria. A rejection later in the same request rolls the reactivation back. - An archived criterion does not count as a duplicate association, so its preserved ObjectTag does not block re-associating the content. Co-Authored-By: Claude Opus 5.5 --- src/openedx_learning/applets/cbe/api.py | 42 ++++- .../openedx_learning/applets/cbe/test_api.py | 170 ++++++++++++++++++ .../applets/cbe/test_views.py | 85 +++++++++ 3 files changed, 289 insertions(+), 8 deletions(-) diff --git a/src/openedx_learning/applets/cbe/api.py b/src/openedx_learning/applets/cbe/api.py index 29205bb0d..1010a05b9 100644 --- a/src/openedx_learning/applets/cbe/api.py +++ b/src/openedx_learning/applets/cbe/api.py @@ -292,21 +292,35 @@ def _create_leaf_group( The root and course-level lookups need no lock of their own: the unique constraints hold one root per tag and one course-level group per tag and course, and ``get_or_create()`` recovers from a concurrent insert. The caller's tag lock is for ``_reject_duplicate_criterion()``. + + An archived root is reactivated in place. Raises ValidationError (keyed "object_id") if the + course-level group is archived. + + Must run inside a transaction holding the tag row lock. """ name_max_length = CompetencyCriteriaGroup._meta.get_field("name").max_length - # Both get_or_create() lookups match archived rows deliberately, so an archived container is - # never replaced; rejecting or reactivating it is the archived-group checks' decision. - root, _ = CompetencyCriteriaGroup.objects.get_or_create( + # Reactivate rather than reject an archived root: it is archived once its last course is, so rejecting + # would lock the competency. The throwaway name avoids shadowing the gettext alias ``_``. + root, _created = CompetencyCriteriaGroup.objects.get_or_create( tag=tag, parent=None, defaults={"name": f"{tag.value} (root)"[:name_max_length]}, ) - course_level_group, _ = CompetencyCriteriaGroup.objects.get_or_create( + if root.archived: + # save(), not update(), so simple_history records the reactivation. + root.archived = False + root.save() + course_level_group, _created = CompetencyCriteriaGroup.objects.get_or_create( tag=tag, course=course_run, parent=root, defaults={"name": f"{tag.value} — {course_run.title}"[:name_max_length]}, ) + if course_level_group.archived: + raise ValidationError({"object_id": _( + "The course-level CompetencyCriteriaGroup for this competency and course is archived " + "and no longer accepts new associations." + )}) return CompetencyCriteriaGroup.objects.create( tag=tag, course=None, @@ -323,8 +337,8 @@ def resolve_supplied_leaf_group(group_id: int, tag: Tag, course_run: CourseRun) and archive behavior may change this function's shape without a deprecation cycle. Raises CompetencyCriteriaGroup.DoesNotExist if it doesn't exist, or ValidationError (keyed - "group_id") if it isn't a leaf, doesn't belong to ``tag``, or its course doesn't match - ``course_run``. + "group_id") if it isn't a leaf, doesn't belong to ``tag``, has a course that doesn't match + ``course_run``, or is archived. """ group = CompetencyCriteriaGroup.objects.get(pk=group_id) if group.parent_id is None or group.course_id is not None: @@ -333,6 +347,11 @@ def resolve_supplied_leaf_group(group_id: int, tag: Tag, course_run: CourseRun) raise ValidationError({"group_id": _("group_id must belong to the given competency tag.")}) if group.parent.course_id != course_run.id: raise ValidationError({"group_id": _("group_id must belong to the given course.")}) + # Archiving never leaves an active group under an archived one, so the leaf's own flag is enough. + if group.archived: + raise ValidationError({"group_id": _( + "group_id references an archived CompetencyCriteriaGroup, which no longer accepts new associations." + )}) return group @@ -466,7 +485,9 @@ def create_competency_criterion( def _reject_duplicate_criterion(tag: Tag, object_id: str, group_id: int | None) -> None: """ - Raise ValidationError if ``object_id`` is already associated with ``tag`` (in ``group_id``, when supplied). + Raise ValidationError if an active criterion already associates ``object_id`` with ``tag``. + + The check is scoped to ``group_id`` when supplied. Must run inside a transaction holding the tag row lock. """ @@ -476,7 +497,8 @@ def _reject_duplicate_criterion(tag: Tag, object_id: str, group_id: int | None) ).first() if existing_object_tag is None: return - duplicate_criteria = CompetencyCriterion.objects.filter(object_tag=existing_object_tag) + # An archived criterion keeps its ObjectTag for history; it must not block a fresh association. + duplicate_criteria = CompetencyCriterion.objects.filter(object_tag=existing_object_tag, archived=False) if group_id is None: # Can't know which group slot is intended, so any existing criterion is a duplicate. if duplicate_criteria.exists(): @@ -543,6 +565,10 @@ def associate_competency_criterion( criterion, all inside one transaction so a downstream failure rolls back any group just created. + An archived leaf group (``group_id``) or an archived course-level group is rejected. When no + ``group_id`` is supplied, an archived root group is reactivated. An archived criterion doesn't + count as a duplicate. + Raises ValidationError (never DRF's) on rejected input, including an invalid combination of rule fields or a missing or archived ``rule_profile_id``; Tag.DoesNotExist if ``tag_id`` doesn't resolve to a competency tag; CompetencyCriteriaGroup.DoesNotExist if ``group_id`` diff --git a/tests/openedx_learning/applets/cbe/test_api.py b/tests/openedx_learning/applets/cbe/test_api.py index 52f844e07..6530d2b72 100644 --- a/tests/openedx_learning/applets/cbe/test_api.py +++ b/tests/openedx_learning/applets/cbe/test_api.py @@ -359,6 +359,74 @@ def test_course_level_group_unique_constraint_rejects_a_second_group_for_the_sam CompetencyCriteriaGroup.objects.create(tag=tag, course=course_run, parent=root) +def test_create_leaf_group_reactivates_an_archived_root_and_adds_a_course_level_group_for_a_new_course( + tag: Tag, course_run: CourseRun, organization: Organization +) -> None: + """ + An archived root, left behind once its only course-level group (course X) was archived, is reactivated. + + A call for course Y reuses that same root, adds a Y course-level group and a leaf under it, + and leaves the archived X course-level group alone. + """ + other_course_run = make_course_run(organization, "Python200", "Fall2026") + root = CompetencyCriteriaGroup.objects.create(tag=tag, parent=None, archived=True) + archived_course_level = CompetencyCriteriaGroup.objects.create( + tag=tag, course=course_run, parent=root, archived=True, + ) + # `.history` is a runtime descriptor mypy cannot type, so query the historical model directly. + historical_group = apps.get_model("openedx_learning", "HistoricalCompetencyCriteriaGroup") + history_before = historical_group.objects.filter(id=root.pk).count() + + leaf = _create_leaf_group(tag, other_course_run) + + root.refresh_from_db() + archived_course_level.refresh_from_db() + assert root.archived is False + assert historical_group.objects.filter(id=root.pk).count() == history_before + 1 + assert historical_group.objects.filter(id=root.pk).latest("history_date", "history_id").archived is False + assert CompetencyCriteriaGroup.objects.filter(tag=tag, parent__isnull=True).count() == 1 + assert leaf.parent is not None + assert leaf.parent.course_id == other_course_run.id + assert leaf.parent.parent_id == root.id + assert archived_course_level.archived is True + + +def test_create_leaf_group_adds_a_course_level_group_beside_an_archived_one( + tag: Tag, course_run: CourseRun, organization: Organization +) -> None: + """With an active root, an archived course-level group for course X does not block course Y.""" + other_course_run = make_course_run(organization, "Python200", "Fall2026") + archived_course_level = _create_leaf_group(tag, course_run).parent + assert archived_course_level is not None + root = archived_course_level.parent + assert root is not None + archived_course_level.archived = True + archived_course_level.save() + + leaf = _create_leaf_group(tag, other_course_run) + + assert leaf.parent is not None + assert leaf.parent.course_id == other_course_run.id + assert leaf.parent.parent_id == root.id + root.refresh_from_db() + assert root.archived is False + archived_course_level.refresh_from_db() + assert archived_course_level.archived is True + + +def test_create_leaf_group_rejects_an_archived_course_level_group(tag: Tag, course_run: CourseRun) -> None: + """An archived course-level group for the same tag and course is rejected, and no leaf is created.""" + root = CompetencyCriteriaGroup.objects.create(tag=tag, parent=None) + CompetencyCriteriaGroup.objects.create(tag=tag, course=course_run, parent=root, archived=True) + group_count = CompetencyCriteriaGroup.objects.count() + + with pytest.raises(ValidationError) as exc_info: + _create_leaf_group(tag, course_run) + + assert "archived" in str(exc_info.value.message_dict["object_id"]) + assert CompetencyCriteriaGroup.objects.count() == group_count + + # ============================================================================================== # resolve_supplied_leaf_group # ============================================================================================== @@ -415,6 +483,31 @@ def test_resolve_supplied_leaf_group_rejects_a_group_for_a_different_course( resolve_supplied_leaf_group(leaf.id, tag, course_run) +def test_resolve_supplied_leaf_group_rejects_an_archived_leaf(tag: Tag, course_run: CourseRun) -> None: + """The leaf's course-level group and root are active; the leaf's own archived flag is what rejects it.""" + leaf = _create_leaf_group(tag, course_run) + leaf.archived = True + leaf.save() + + with pytest.raises(ValidationError) as exc_info: + resolve_supplied_leaf_group(leaf.id, tag, course_run) + + assert "archived" in str(exc_info.value.message_dict["group_id"]) + + +def test_resolve_supplied_leaf_group_still_resolves_an_active_leaf_beside_an_archived_one( + tag: Tag, course_run: CourseRun +) -> None: + """Both leaves share one active course-level group.""" + archived_leaf = _create_leaf_group(tag, course_run) + archived_leaf.archived = True + archived_leaf.save() + active_leaf = _create_leaf_group(tag, course_run) + + assert archived_leaf.parent_id == active_leaf.parent_id + assert resolve_supplied_leaf_group(active_leaf.id, tag, course_run) == active_leaf + + # ============================================================================================== # associate_competency_criterion # ============================================================================================== @@ -549,6 +642,83 @@ def _reject(**_kwargs) -> None: assert not CompetencyCriteriaGroup.objects.filter(tag=tag).exists() +def test_associate_competency_criterion_rolls_back_root_reactivation_when_the_course_level_group_is_archived( + tag: Tag, course_run: CourseRun +) -> None: + """Rejecting the archived course-level group leaves the archived root archived and creates no group or criterion.""" + root = CompetencyCriteriaGroup.objects.create(tag=tag, parent=None, archived=True) + CompetencyCriteriaGroup.objects.create(tag=tag, course=course_run, parent=root, archived=True) + group_count = CompetencyCriteriaGroup.objects.count() + criterion_count = CompetencyCriterion.objects.count() + + with pytest.raises(ValidationError) as exc_info: + associate_competency_criterion(tag_id=tag.id, object_id=usage_key(course_run, "p1")) + + assert "archived" in str(exc_info.value.message_dict["object_id"]) + root.refresh_from_db() + assert root.archived is True + assert CompetencyCriteriaGroup.objects.count() == group_count + assert CompetencyCriterion.objects.count() == criterion_count + + +def test_associate_competency_criterion_allows_re_association_while_a_sibling_keeps_the_course_level_group_alive( + tag: Tag, course_run: CourseRun +) -> None: + """ + Re-associating after an archive succeeds while a sibling leaf keeps the course-level group active. + + The setup mirrors the archive cascade: the first criterion and its own leaf are archived, but the + course-level group stays active because the sibling's leaf is still live. The new criterion gets its own + leaf under that same course-level group and reuses the ObjectTag. + """ + first = associate_competency_criterion(tag_id=tag.id, object_id=usage_key(course_run, "p1")) + sibling = associate_competency_criterion(tag_id=tag.id, object_id=usage_key(course_run, "p2")) + first.archived = True + first.save() + first.group.archived = True + first.group.save() + + second = associate_competency_criterion(tag_id=tag.id, object_id=usage_key(course_run, "p1")) + + first.refresh_from_db() + assert second.id != first.id + assert second.group_id != first.group_id + assert second.object_tag_id == first.object_tag_id + assert second.group.parent_id == sibling.group.parent_id + assert first.archived is True + + +def test_associate_competency_criterion_rejects_re_association_once_the_whole_branch_is_archived( + tag: Tag, course_run: CourseRun +) -> None: + """ + Re-associating is rejected when the archive cascade has archived the whole branch. + + This pins the current answer to the ticket's open question about re-association after a cascade archive: + the criterion, its leaf, the course-level group, and the root are all archived, and the new criterion is + refused because the course-level group is archived. + """ + object_id = usage_key(course_run, "p1") + first = associate_competency_criterion(tag_id=tag.id, object_id=object_id) + leaf = first.group + course_level = leaf.parent + assert course_level is not None + root = course_level.parent + assert root is not None + for instance in (first, leaf, course_level, root): + instance.archived = True + instance.save() + criterion_count = CompetencyCriterion.objects.count() + + with pytest.raises(ValidationError) as exc_info: + associate_competency_criterion(tag_id=tag.id, object_id=object_id) + + assert "archived" in str(exc_info.value.message_dict["object_id"]) + root.refresh_from_db() + assert root.archived is True + assert CompetencyCriterion.objects.count() == criterion_count + + # ============================================================================================== # bulk_update_competency_criteria (#759) # ============================================================================================== diff --git a/tests/openedx_learning/applets/cbe/test_views.py b/tests/openedx_learning/applets/cbe/test_views.py index be957f857..2f1c17bd2 100644 --- a/tests/openedx_learning/applets/cbe/test_views.py +++ b/tests/openedx_learning/applets/cbe/test_views.py @@ -553,6 +553,91 @@ def test_duplicate_association_via_the_derive_path_is_rejected_before_creating_a assert CompetencyCriteriaGroup.objects.count() == groups_before +def test_supplied_archived_leaf_group_is_rejected(user_client: APIClient, tag: Tag, course_run: CourseRun) -> None: + """The 400 is keyed group_id, and no criterion is created.""" + leaf = _create_leaf_group(tag, course_run) + leaf.archived = True + leaf.save() + object_id = usage_key(course_run, "p1") + + response = user_client.post( + criterion_create_url(tag.id), {"object_id": object_id, "group_id": leaf.id}, format="json", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "archived" in str(response.data["group_id"]) + assert not CompetencyCriterion.objects.exists() + + +def test_derive_path_with_an_archived_course_level_group_is_rejected( + user_client: APIClient, tag: Tag, course_run: CourseRun, +) -> None: + """With no group_id, an archived course-level group for the object's course is a 400 and creates nothing.""" + root = CompetencyCriteriaGroup.objects.create(tag=tag, parent=None) + CompetencyCriteriaGroup.objects.create(tag=tag, course=course_run, parent=root, archived=True) + groups_before = CompetencyCriteriaGroup.objects.count() + + response = user_client.post( + criterion_create_url(tag.id), {"object_id": usage_key(course_run, "p1")}, format="json", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "archived" in str(response.data["object_id"]) + assert CompetencyCriteriaGroup.objects.count() == groups_before + assert not CompetencyCriterion.objects.exists() + + +def test_derive_path_reactivates_an_archived_root_instead_of_creating_a_second_one( + user_client: APIClient, tag: Tag, course_run: CourseRun, organization: Organization, +) -> None: + """An archived root whose only course-level group (another course) is archived is reactivated and reused.""" + other_course_run = make_course_run(organization, "Python200", "Fall2026") + root = CompetencyCriteriaGroup.objects.create(tag=tag, parent=None, archived=True) + other_course_level = CompetencyCriteriaGroup.objects.create( + tag=tag, course=other_course_run, parent=root, archived=True, + ) + + response = user_client.post( + criterion_create_url(tag.id), {"object_id": usage_key(course_run, "p1")}, format="json", + ) + + assert response.status_code == status.HTTP_201_CREATED + root.refresh_from_db() + assert root.archived is False + assert CompetencyCriteriaGroup.objects.filter(tag=tag, parent__isnull=True).count() == 1 + criterion = CompetencyCriterion.objects.get(pk=response.data["id"]) + assert criterion.group.parent is not None + assert criterion.group.parent.parent_id == root.id + assert criterion.group.parent.course_id == course_run.id + other_course_level.refresh_from_db() + assert other_course_level.archived is True + + +def test_re_association_while_a_sibling_keeps_the_course_level_group_alive_is_created( + user_client: APIClient, tag: Tag, course_run: CourseRun, +) -> None: + """ + A fresh association of an archived (tag, object_id) is created while a sibling keeps the course-level group alive. + + The archive state mirrors the cascade: the first criterion and its leaf are archived, the sibling's leaf is not. + """ + object_id = usage_key(course_run, "p1") + first = user_client.post(criterion_create_url(tag.id), {"object_id": object_id}, format="json") + sibling = user_client.post(criterion_create_url(tag.id), {"object_id": usage_key(course_run, "p2")}, format="json") + assert first.status_code == status.HTTP_201_CREATED + assert sibling.status_code == status.HTTP_201_CREATED + criterion = CompetencyCriterion.objects.get(pk=first.data["id"]) + criterion.archived = True + criterion.save() + criterion.group.archived = True + criterion.group.save() + + response = user_client.post(criterion_create_url(tag.id), {"object_id": object_id}, format="json") + + assert response.status_code == status.HTTP_201_CREATED + assert response.data["id"] != first.data["id"] + + def test_all_null_rule_fields_resolve_to_the_system_default_profile_id( user_client: APIClient, tag: Tag, course_run: CourseRun, default_rule_profile: CompetencyRuleProfile, ) -> None: From b70160d68d31982e539fae0b76f52f5d21aa28d1 Mon Sep 17 00:00:00 2001 From: ufedaseyeuconsultant Date: Tue, 10 Nov 2026 15:08:54 +0300 Subject: [PATCH 2/4] fix: reject supplied leaves under an archived group (#717) The supplied-path leaf check looked only at the leaf's own archived flag. A leaf written outside the API under an archived course-level group was accepted, while the derive path for the same course was rejected. resolve_supplied_leaf_group() now also rejects when the leaf's course-level group is archived, keyed "group_id". The check mirrors get_courses_with_relative_criteria(), which already treats such a leaf as dead. Comment and docstring corrections: - _create_leaf_group() states what the root reactivation and the two archived checks rely on: the caller's tag lock serializes them against other creates, but not against the archive cascade until #674 and #675 take the same lock. - Both get_or_create() lookups say why they match archived rows, and the root reactivation cites ADR 0002 Decision 2 (#872). - The duplicate check comment says any active criterion is a duplicate. - associate_competency_criterion() says a failure also rolls back a root reactivation and lists the archived-group rejections with their error keys. Co-Authored-By: Claude Fable 5 --- src/openedx_learning/applets/cbe/api.py | 25 +++++++++++++------ .../openedx_learning/applets/cbe/test_api.py | 20 +++++++++++++++ 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/src/openedx_learning/applets/cbe/api.py b/src/openedx_learning/applets/cbe/api.py index 1010a05b9..499849765 100644 --- a/src/openedx_learning/applets/cbe/api.py +++ b/src/openedx_learning/applets/cbe/api.py @@ -296,11 +296,15 @@ def _create_leaf_group( An archived root is reactivated in place. Raises ValidationError (keyed "object_id") if the course-level group is archived. - Must run inside a transaction holding the tag row lock. + Must run inside a transaction holding the tag row lock. That lock serializes the reactivation + and the two archived checks against other creates, but not against the archive cascade until + the endpoints for #674 and #675, which take the same lock, land. """ name_max_length = CompetencyCriteriaGroup._meta.get_field("name").max_length + # Both lookups match archived rows on purpose: the unique constraints leave no slot for a replacement. # Reactivate rather than reject an archived root: it is archived once its last course is, so rejecting - # would lock the competency. The throwaway name avoids shadowing the gettext alias ``_``. + # would lock the competency. ADR 0002 Decision 2 records this as the one reversible exception to + # permanent archiving (#872). The throwaway name avoids shadowing the gettext alias ``_``. root, _created = CompetencyCriteriaGroup.objects.get_or_create( tag=tag, parent=None, @@ -338,7 +342,7 @@ def resolve_supplied_leaf_group(group_id: int, tag: Tag, course_run: CourseRun) Raises CompetencyCriteriaGroup.DoesNotExist if it doesn't exist, or ValidationError (keyed "group_id") if it isn't a leaf, doesn't belong to ``tag``, has a course that doesn't match - ``course_run``, or is archived. + ``course_run``, or is archived or sits under an archived course-level group. """ group = CompetencyCriteriaGroup.objects.get(pk=group_id) if group.parent_id is None or group.course_id is not None: @@ -347,11 +351,16 @@ def resolve_supplied_leaf_group(group_id: int, tag: Tag, course_run: CourseRun) raise ValidationError({"group_id": _("group_id must belong to the given competency tag.")}) if group.parent.course_id != course_run.id: raise ValidationError({"group_id": _("group_id must belong to the given course.")}) - # Archiving never leaves an active group under an archived one, so the leaf's own flag is enough. if group.archived: raise ValidationError({"group_id": _( "group_id references an archived CompetencyCriteriaGroup, which no longer accepts new associations." )}) + # Mirrors the defense get_courses_with_relative_criteria() applies to rows written outside the API. + if group.parent.archived: + raise ValidationError({"group_id": _( + "The course-level CompetencyCriteriaGroup of group_id is archived and no longer accepts " + "new associations." + )}) return group @@ -500,7 +509,7 @@ def _reject_duplicate_criterion(tag: Tag, object_id: str, group_id: int | None) # An archived criterion keeps its ObjectTag for history; it must not block a fresh association. duplicate_criteria = CompetencyCriterion.objects.filter(object_tag=existing_object_tag, archived=False) if group_id is None: - # Can't know which group slot is intended, so any existing criterion is a duplicate. + # Can't know which group slot is intended, so any active criterion is a duplicate. if duplicate_criteria.exists(): raise ValidationError( {"object_id": _("A CompetencyCriterion already associates this tag with this object_id.")} @@ -563,7 +572,7 @@ def associate_competency_criterion( path; a *different* explicit group is a deliberate second association per ADR-0002, not a duplicate. Branches on ``group_id`` to resolve or derive/create a leaf, then creates the criterion, all inside one transaction so a downstream failure rolls back any group just - created. + created and any root reactivation. An archived leaf group (``group_id``) or an archived course-level group is rejected. When no ``group_id`` is supplied, an archived root group is reactivated. An archived criterion doesn't @@ -572,7 +581,9 @@ def associate_competency_criterion( Raises ValidationError (never DRF's) on rejected input, including an invalid combination of rule fields or a missing or archived ``rule_profile_id``; Tag.DoesNotExist if ``tag_id`` doesn't resolve to a competency tag; CompetencyCriteriaGroup.DoesNotExist if ``group_id`` - doesn't resolve. + doesn't resolve. The archived-group rejections are keyed "object_id" when the derive path + finds the course's course-level group archived, and "group_id" when the supplied leaf, or the + course-level group above it, is archived. """ tag = resolve_competency_tag(tag_id) # tag.taxonomy is already confirmed set; re-asserted since that doesn't cross function boundaries for mypy. diff --git a/tests/openedx_learning/applets/cbe/test_api.py b/tests/openedx_learning/applets/cbe/test_api.py index 6530d2b72..1d7275b97 100644 --- a/tests/openedx_learning/applets/cbe/test_api.py +++ b/tests/openedx_learning/applets/cbe/test_api.py @@ -495,6 +495,26 @@ def test_resolve_supplied_leaf_group_rejects_an_archived_leaf(tag: Tag, course_r assert "archived" in str(exc_info.value.message_dict["group_id"]) +def test_resolve_supplied_leaf_group_rejects_an_active_leaf_under_an_archived_course_level_group( + tag: Tag, course_run: CourseRun +) -> None: + """ + An active leaf under an archived course-level group is rejected, keyed ``group_id``. + + Only rows written outside the API produce this state. The check mirrors + ``get_courses_with_relative_criteria()``, which also treats such a leaf as archived. + """ + leaf = _create_leaf_group(tag, course_run) + assert leaf.parent is not None + leaf.parent.archived = True + leaf.parent.save() + + with pytest.raises(ValidationError) as exc_info: + resolve_supplied_leaf_group(leaf.id, tag, course_run) + + assert "archived" in str(exc_info.value.message_dict["group_id"]) + + def test_resolve_supplied_leaf_group_still_resolves_an_active_leaf_beside_an_archived_one( tag: Tag, course_run: CourseRun ) -> None: From e1cf491414a401a750d55309caa6c96fa0a8286c Mon Sep 17 00:00:00 2001 From: ufedaseyeuconsultant Date: Tue, 10 Nov 2026 15:08:54 +0300 Subject: [PATCH 3/4] test: cover supplied re-association and dominance rollback (#717) Add a view test for scenario 6 of #717: a supplied group_id of an active leaf that holds only an archived criterion for the same content returns 201. Add a unit test that a request rejected by the hierarchy-dominance check after the root was reactivated leaves the root archived and creates nothing. Test hygiene: - Drop the unit test for the archived course-level group rejection, which the scenario 4 view test and the two scenario 3 associate_competency_criterion tests already cover. - Replace two assertions that cannot fail (new id differs from old id) and one redundant history-count assertion. - Compare ObjectTag reuse in the scenario 7 view test. - Rename the whole-branch test for what it pins, cite scenario 3, and state in both scenario 3 docstrings that they guard the transaction.atomic() wrapper. - Give the archived course-level group view test a docstring with the error key and the no-leaf outcome. Co-Authored-By: Claude Fable 5 --- .../openedx_learning/applets/cbe/test_api.py | 67 +++++++++++++------ .../applets/cbe/test_views.py | 39 ++++++++++- 2 files changed, 81 insertions(+), 25 deletions(-) diff --git a/tests/openedx_learning/applets/cbe/test_api.py b/tests/openedx_learning/applets/cbe/test_api.py index 1d7275b97..9dfe6ec41 100644 --- a/tests/openedx_learning/applets/cbe/test_api.py +++ b/tests/openedx_learning/applets/cbe/test_api.py @@ -375,14 +375,12 @@ def test_create_leaf_group_reactivates_an_archived_root_and_adds_a_course_level_ ) # `.history` is a runtime descriptor mypy cannot type, so query the historical model directly. historical_group = apps.get_model("openedx_learning", "HistoricalCompetencyCriteriaGroup") - history_before = historical_group.objects.filter(id=root.pk).count() leaf = _create_leaf_group(tag, other_course_run) root.refresh_from_db() archived_course_level.refresh_from_db() assert root.archived is False - assert historical_group.objects.filter(id=root.pk).count() == history_before + 1 assert historical_group.objects.filter(id=root.pk).latest("history_date", "history_id").archived is False assert CompetencyCriteriaGroup.objects.filter(tag=tag, parent__isnull=True).count() == 1 assert leaf.parent is not None @@ -414,19 +412,6 @@ def test_create_leaf_group_adds_a_course_level_group_beside_an_archived_one( assert archived_course_level.archived is True -def test_create_leaf_group_rejects_an_archived_course_level_group(tag: Tag, course_run: CourseRun) -> None: - """An archived course-level group for the same tag and course is rejected, and no leaf is created.""" - root = CompetencyCriteriaGroup.objects.create(tag=tag, parent=None) - CompetencyCriteriaGroup.objects.create(tag=tag, course=course_run, parent=root, archived=True) - group_count = CompetencyCriteriaGroup.objects.count() - - with pytest.raises(ValidationError) as exc_info: - _create_leaf_group(tag, course_run) - - assert "archived" in str(exc_info.value.message_dict["object_id"]) - assert CompetencyCriteriaGroup.objects.count() == group_count - - # ============================================================================================== # resolve_supplied_leaf_group # ============================================================================================== @@ -665,7 +650,12 @@ def _reject(**_kwargs) -> None: def test_associate_competency_criterion_rolls_back_root_reactivation_when_the_course_level_group_is_archived( tag: Tag, course_run: CourseRun ) -> None: - """Rejecting the archived course-level group leaves the archived root archived and creates no group or criterion.""" + """ + Rejecting the archived course-level group leaves the archived root archived and creates no group or criterion. + + Scenario 3 of #717. Together with the whole-branch test below, this guards the ``transaction.atomic()`` + wrapper: without it the root reactivation commits even though the request is rejected. + """ root = CompetencyCriteriaGroup.objects.create(tag=tag, parent=None, archived=True) CompetencyCriteriaGroup.objects.create(tag=tag, course=course_run, parent=root, archived=True) group_count = CompetencyCriteriaGroup.objects.count() @@ -701,22 +691,23 @@ def test_associate_competency_criterion_allows_re_association_while_a_sibling_ke second = associate_competency_criterion(tag_id=tag.id, object_id=usage_key(course_run, "p1")) first.refresh_from_db() - assert second.id != first.id assert second.group_id != first.group_id assert second.object_tag_id == first.object_tag_id assert second.group.parent_id == sibling.group.parent_id assert first.archived is True -def test_associate_competency_criterion_rejects_re_association_once_the_whole_branch_is_archived( +def test_associate_competency_criterion_archived_criterion_does_not_mask_the_archived_branch_error( tag: Tag, course_run: CourseRun ) -> None: """ - Re-associating is rejected when the archive cascade has archived the whole branch. + An archived criterion for the same content does not hide the archived-branch rejection. - This pins the current answer to the ticket's open question about re-association after a cascade archive: - the criterion, its leaf, the course-level group, and the root are all archived, and the new criterion is - refused because the course-level group is archived. + Scenario 3 of #717 with the archive cascade's full result: the criterion, its leaf, the course-level + group, and the root are all archived. The duplicate check ignores the archived criterion, so the request + reaches the archived course-level group and is refused there, keyed ``object_id``. Like the test above, + this guards the ``transaction.atomic()`` wrapper: the root stays archived only because the rejection rolls + back the reactivation. """ object_id = usage_key(course_run, "p1") first = associate_competency_criterion(tag_id=tag.id, object_id=object_id) @@ -739,6 +730,38 @@ def test_associate_competency_criterion_rejects_re_association_once_the_whole_br assert CompetencyCriterion.objects.count() == criterion_count +def test_associate_competency_criterion_rolls_back_root_reactivation_when_the_dominance_check_rejects( + competency_taxonomy: CompetencyTaxonomy, + course_run: CourseRun, + organization: Organization, + default_rule_profile: CompetencyRuleProfile, +) -> None: + """ + A request rejected by the hierarchy-dominance check after the root was reactivated rolls back everything. + + ``create_competency_criterion()`` runs the check after ``_create_leaf_group()``, so by then the archived + root is active again and the course-level group and leaf exist. The rejection must leave the root archived + and create no group or criterion for the competency. + """ + parent = make_tag(competency_taxonomy, "Ancestor Skill") + child = make_tag(competency_taxonomy, "Descendant Skill", parent=parent) + make_leaf_criterion(parent, course_run, default_rule_profile, "p1") + other_course_run = make_course_run(organization, "Python200", "Fall2026") + root = CompetencyCriteriaGroup.objects.create(tag=child, parent=None, archived=True) + CompetencyCriteriaGroup.objects.create(tag=child, course=other_course_run, parent=root, archived=True) + group_count = CompetencyCriteriaGroup.objects.count() + criterion_count = CompetencyCriterion.objects.count() + + with pytest.raises(ValidationError) as exc_info: + associate_competency_criterion(tag_id=child.id, object_id=usage_key(course_run, "p2")) + + assert "tag_id" in exc_info.value.message_dict + root.refresh_from_db() + assert root.archived is True + assert CompetencyCriteriaGroup.objects.count() == group_count + assert CompetencyCriterion.objects.count() == criterion_count + + # ============================================================================================== # bulk_update_competency_criteria (#759) # ============================================================================================== diff --git a/tests/openedx_learning/applets/cbe/test_views.py b/tests/openedx_learning/applets/cbe/test_views.py index 2f1c17bd2..289768700 100644 --- a/tests/openedx_learning/applets/cbe/test_views.py +++ b/tests/openedx_learning/applets/cbe/test_views.py @@ -539,7 +539,7 @@ def test_duplicate_association_via_the_derive_path_is_rejected_before_creating_a A second criterion for the same (tag, object_id), with no group_id, is rejected before any new group. Rejected regardless of which group the existing criterion is actually in: with no group_id - supplied, there's no way to know which group slot this request intends, so any existing + supplied, there's no way to know which group slot this request intends, so any active criterion for the pair counts as a duplicate. """ object_id = usage_key(course_run, "p1") @@ -572,7 +572,11 @@ def test_supplied_archived_leaf_group_is_rejected(user_client: APIClient, tag: T def test_derive_path_with_an_archived_course_level_group_is_rejected( user_client: APIClient, tag: Tag, course_run: CourseRun, ) -> None: - """With no group_id, an archived course-level group for the object's course is a 400 and creates nothing.""" + """ + With no group_id, an archived course-level group for the object's course is a 400 keyed ``object_id``. + + The root is active, so nothing is reactivated, and the request creates no leaf and no criterion. + """ root = CompetencyCriteriaGroup.objects.create(tag=tag, parent=None) CompetencyCriteriaGroup.objects.create(tag=tag, course=course_run, parent=root, archived=True) groups_before = CompetencyCriteriaGroup.objects.count() @@ -635,7 +639,36 @@ def test_re_association_while_a_sibling_keeps_the_course_level_group_alive_is_cr response = user_client.post(criterion_create_url(tag.id), {"object_id": object_id}, format="json") assert response.status_code == status.HTTP_201_CREATED - assert response.data["id"] != first.data["id"] + new_criterion = CompetencyCriterion.objects.get(pk=response.data["id"]) + assert new_criterion.object_tag_id == criterion.object_tag_id + + +def test_supplied_group_holding_an_archived_criterion_for_the_same_content_accepts_a_new_one( + user_client: APIClient, tag: Tag, course_run: CourseRun, +) -> None: + """ + Scenario 6 of #717 at the POST level: an archived criterion does not block re-association into its own leaf. + + The leaf is active and holds only an archived criterion for the content. A request supplying that + ``group_id`` creates a second criterion in the same leaf on the same ObjectTag, and the archived one is untouched. + """ + object_id = usage_key(course_run, "p1") + first = user_client.post(criterion_create_url(tag.id), {"object_id": object_id}, format="json") + assert first.status_code == status.HTTP_201_CREATED + archived_criterion = CompetencyCriterion.objects.get(pk=first.data["id"]) + archived_criterion.archived = True + archived_criterion.save() + + response = user_client.post( + criterion_create_url(tag.id), {"object_id": object_id, "group_id": first.data["group_id"]}, format="json", + ) + + assert response.status_code == status.HTTP_201_CREATED + new_criterion = CompetencyCriterion.objects.get(pk=response.data["id"]) + assert new_criterion.group_id == archived_criterion.group_id + assert new_criterion.object_tag_id == archived_criterion.object_tag_id + archived_criterion.refresh_from_db() + assert archived_criterion.archived is True def test_all_null_rule_fields_resolve_to_the_system_default_profile_id( From 2e09d1cff982abf823b1722e833578ff5a8ddbc4 Mon Sep 17 00:00:00 2001 From: ufedaseyeuconsultant Date: Tue, 10 Nov 2026 19:24:52 +0300 Subject: [PATCH 4/4] fix: let archived criteria keep blocking re-association (#717) Per the ticket owner's decision on #717, _reject_duplicate_criterion() no longer filters out archived criteria. An archived criterion for a content keeps blocking a new criterion for the same content in the same group, and, when no group_id is supplied, anywhere under the competency. ADR 0002 makes archiving permanent below the root for the MVP, and keeping at most one criterion row per content per group leaves the data clean for a post-MVP unarchive. A different explicit group stays a deliberate second association. The 400 now names the archived state when every matching criterion is archived: the object_id or group_id key is unchanged, and the message says the content was previously associated and the association was archived. When an active criterion matches, the plain duplicate messages apply. Co-Authored-By: Claude Fable 5 --- src/openedx_learning/applets/cbe/api.py | 47 +++++++++----- .../openedx_learning/applets/cbe/test_api.py | 62 +++++++++++++------ .../applets/cbe/test_views.py | 54 +++++++++++++--- 3 files changed, 122 insertions(+), 41 deletions(-) diff --git a/src/openedx_learning/applets/cbe/api.py b/src/openedx_learning/applets/cbe/api.py index 499849765..4aab2d77f 100644 --- a/src/openedx_learning/applets/cbe/api.py +++ b/src/openedx_learning/applets/cbe/api.py @@ -494,9 +494,11 @@ def create_competency_criterion( def _reject_duplicate_criterion(tag: Tag, object_id: str, group_id: int | None) -> None: """ - Raise ValidationError if an active criterion already associates ``object_id`` with ``tag``. + Raise ValidationError if a criterion, active or archived, already associates ``object_id`` with ``tag``. - The check is scoped to ``group_id`` when supplied. + The check is scoped to ``group_id`` when supplied. The error is keyed ``object_id`` without a ``group_id`` + and ``group_id`` with one. When every matching criterion is archived, the message says the association was + archived. Must run inside a transaction holding the tag row lock. """ @@ -506,19 +508,34 @@ def _reject_duplicate_criterion(tag: Tag, object_id: str, group_id: int | None) ).first() if existing_object_tag is None: return - # An archived criterion keeps its ObjectTag for history; it must not block a fresh association. - duplicate_criteria = CompetencyCriterion.objects.filter(object_tag=existing_object_tag, archived=False) + # An archived criterion keeps blocking: ADR 0002 makes archiving permanent below the root for the MVP, and a + # criterion is no exception. At most one criterion row per content per group also leaves the data clean for a + # post-MVP unarchive (ticket owner's decision on #717). + duplicate_criteria = CompetencyCriterion.objects.filter(object_tag=existing_object_tag) if group_id is None: - # Can't know which group slot is intended, so any active criterion is a duplicate. - if duplicate_criteria.exists(): - raise ValidationError( - {"object_id": _("A CompetencyCriterion already associates this tag with this object_id.")} - ) - elif duplicate_criteria.filter(group_id=group_id).exists(): - # A different explicit group is a deliberate second association (ADR-0002), not a duplicate. + # Can't know which group slot is intended, so any criterion is a duplicate. + archived_flags = list(duplicate_criteria.values_list("archived", flat=True)) + if not archived_flags: + return + if all(archived_flags): + raise ValidationError({"object_id": _( + "This content was previously associated with this competency and the association was archived." + )}) raise ValidationError( - {"group_id": _("A CompetencyCriterion already associates this tag with this object_id in this group.")} + {"object_id": _("A CompetencyCriterion already associates this tag with this object_id.")} ) + # A different explicit group is a deliberate second association (ADR-0002), not a duplicate. + archived_flags = list(duplicate_criteria.filter(group_id=group_id).values_list("archived", flat=True)) + if not archived_flags: + return + if all(archived_flags): + raise ValidationError({"group_id": _( + "This content was previously associated with this competency in this group and the association " + "was archived." + )}) + raise ValidationError( + {"group_id": _("A CompetencyCriterion already associates this tag with this object_id in this group.")} + ) def _validate_rule_fields( @@ -575,8 +592,10 @@ def associate_competency_criterion( created and any root reactivation. An archived leaf group (``group_id``) or an archived course-level group is rejected. When no - ``group_id`` is supplied, an archived root group is reactivated. An archived criterion doesn't - count as a duplicate. + ``group_id`` is supplied, an archived root group is reactivated. An archived criterion blocks + re-association like an active one: in the supplied group, or anywhere under the competency + when no ``group_id`` is supplied. The error then says the association was archived, under the + same ``object_id`` or ``group_id`` key as an active duplicate. Raises ValidationError (never DRF's) on rejected input, including an invalid combination of rule fields or a missing or archived ``rule_profile_id``; Tag.DoesNotExist if ``tag_id`` diff --git a/tests/openedx_learning/applets/cbe/test_api.py b/tests/openedx_learning/applets/cbe/test_api.py index 9dfe6ec41..d1a5ab9aa 100644 --- a/tests/openedx_learning/applets/cbe/test_api.py +++ b/tests/openedx_learning/applets/cbe/test_api.py @@ -671,43 +671,69 @@ def test_associate_competency_criterion_rolls_back_root_reactivation_when_the_co assert CompetencyCriterion.objects.count() == criterion_count -def test_associate_competency_criterion_allows_re_association_while_a_sibling_keeps_the_course_level_group_alive( +def test_associate_competency_criterion_archived_criterion_blocks_re_association_while_a_sibling_keeps_the_group_alive( tag: Tag, course_run: CourseRun ) -> None: """ - Re-associating after an archive succeeds while a sibling leaf keeps the course-level group active. + An archived criterion for the content blocks a derive-path re-add even while the course-level group is active. The setup mirrors the archive cascade: the first criterion and its own leaf are archived, but the - course-level group stays active because the sibling's leaf is still live. The new criterion gets its own - leaf under that same course-level group and reuses the ObjectTag. + course-level group stays active because the sibling's leaf is still live. With no ``group_id``, the archived + criterion blocks anywhere under the competency, and the error says the association was archived. """ first = associate_competency_criterion(tag_id=tag.id, object_id=usage_key(course_run, "p1")) - sibling = associate_competency_criterion(tag_id=tag.id, object_id=usage_key(course_run, "p2")) + associate_competency_criterion(tag_id=tag.id, object_id=usage_key(course_run, "p2")) first.archived = True first.save() first.group.archived = True first.group.save() + group_count = CompetencyCriteriaGroup.objects.count() + criterion_count = CompetencyCriterion.objects.count() - second = associate_competency_criterion(tag_id=tag.id, object_id=usage_key(course_run, "p1")) + with pytest.raises(ValidationError) as exc_info: + associate_competency_criterion(tag_id=tag.id, object_id=usage_key(course_run, "p1")) - first.refresh_from_db() - assert second.group_id != first.group_id - assert second.object_tag_id == first.object_tag_id - assert second.group.parent_id == sibling.group.parent_id - assert first.archived is True + assert "previously associated" in str(exc_info.value.message_dict["object_id"]) + assert "archived" in str(exc_info.value.message_dict["object_id"]) + assert CompetencyCriteriaGroup.objects.count() == group_count + assert CompetencyCriterion.objects.count() == criterion_count + + +def test_associate_competency_criterion_active_match_keeps_the_plain_duplicate_message_beside_an_archived_one( + tag: Tag, course_run: CourseRun +) -> None: + """ + When an active and an archived criterion both match the content, the plain duplicate message applies. + The archived-state message is reserved for the case where every matching criterion is archived. + """ + object_id = usage_key(course_run, "p1") + archived = associate_competency_criterion(tag_id=tag.id, object_id=object_id) + active = associate_competency_criterion( + tag_id=tag.id, object_id=object_id, group_id=_create_leaf_group(tag, course_run).id, + ) + archived.archived = True + archived.save() -def test_associate_competency_criterion_archived_criterion_does_not_mask_the_archived_branch_error( + with pytest.raises(ValidationError) as exc_info: + associate_competency_criterion(tag_id=tag.id, object_id=object_id) + with pytest.raises(ValidationError) as group_exc_info: + associate_competency_criterion(tag_id=tag.id, object_id=object_id, group_id=active.group_id) + + assert "previously" not in str(exc_info.value.message_dict["object_id"]) + assert "previously" not in str(group_exc_info.value.message_dict["group_id"]) + + +def test_associate_competency_criterion_archived_criterion_blocks_re_add_before_the_branch_state_is_consulted( tag: Tag, course_run: CourseRun ) -> None: """ - An archived criterion for the same content does not hide the archived-branch rejection. + An archived criterion blocks the re-add before the archived branch is checked. Scenario 3 of #717 with the archive cascade's full result: the criterion, its leaf, the course-level - group, and the root are all archived. The duplicate check ignores the archived criterion, so the request - reaches the archived course-level group and is refused there, keyed ``object_id``. Like the test above, - this guards the ``transaction.atomic()`` wrapper: the root stays archived only because the rejection rolls - back the reactivation. + group, and the root are all archived. The duplicate check runs first and refuses the request with the + archived-duplicate message keyed ``object_id``, not the archived-course-level-group message, and the root + is not reactivated. """ object_id = usage_key(course_run, "p1") first = associate_competency_criterion(tag_id=tag.id, object_id=object_id) @@ -724,7 +750,7 @@ def test_associate_competency_criterion_archived_criterion_does_not_mask_the_arc with pytest.raises(ValidationError) as exc_info: associate_competency_criterion(tag_id=tag.id, object_id=object_id) - assert "archived" in str(exc_info.value.message_dict["object_id"]) + assert "previously associated" in str(exc_info.value.message_dict["object_id"]) root.refresh_from_db() assert root.archived is True assert CompetencyCriterion.objects.count() == criterion_count diff --git a/tests/openedx_learning/applets/cbe/test_views.py b/tests/openedx_learning/applets/cbe/test_views.py index 289768700..48fbf4963 100644 --- a/tests/openedx_learning/applets/cbe/test_views.py +++ b/tests/openedx_learning/applets/cbe/test_views.py @@ -617,13 +617,15 @@ def test_derive_path_reactivates_an_archived_root_instead_of_creating_a_second_o assert other_course_level.archived is True -def test_re_association_while_a_sibling_keeps_the_course_level_group_alive_is_created( +def test_re_association_while_a_sibling_keeps_the_course_level_group_alive_is_rejected_as_archived( user_client: APIClient, tag: Tag, course_run: CourseRun, ) -> None: """ - A fresh association of an archived (tag, object_id) is created while a sibling keeps the course-level group alive. + A derive-path POST for an archived (tag, object_id) is a 400 keyed ``object_id`` even with a live sibling. The archive state mirrors the cascade: the first criterion and its leaf are archived, the sibling's leaf is not. + With no ``group_id``, the archived criterion blocks anywhere under the competency, and the message names the + archived state. """ object_id = usage_key(course_run, "p1") first = user_client.post(criterion_create_url(tag.id), {"object_id": object_id}, format="json") @@ -635,22 +637,26 @@ def test_re_association_while_a_sibling_keeps_the_course_level_group_alive_is_cr criterion.save() criterion.group.archived = True criterion.group.save() + group_count = CompetencyCriteriaGroup.objects.count() + criterion_count = CompetencyCriterion.objects.count() response = user_client.post(criterion_create_url(tag.id), {"object_id": object_id}, format="json") - assert response.status_code == status.HTTP_201_CREATED - new_criterion = CompetencyCriterion.objects.get(pk=response.data["id"]) - assert new_criterion.object_tag_id == criterion.object_tag_id + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "previously associated" in str(response.data["object_id"]) + assert "archived" in str(response.data["object_id"]) + assert CompetencyCriteriaGroup.objects.count() == group_count + assert CompetencyCriterion.objects.count() == criterion_count -def test_supplied_group_holding_an_archived_criterion_for_the_same_content_accepts_a_new_one( +def test_supplied_group_holding_an_archived_criterion_for_the_same_content_is_rejected_as_archived( user_client: APIClient, tag: Tag, course_run: CourseRun, ) -> None: """ - Scenario 6 of #717 at the POST level: an archived criterion does not block re-association into its own leaf. + Scenario 6 of #717 at the POST level: an archived criterion blocks re-association into its own leaf. The leaf is active and holds only an archived criterion for the content. A request supplying that - ``group_id`` creates a second criterion in the same leaf on the same ObjectTag, and the archived one is untouched. + ``group_id`` is a 400 keyed ``group_id`` whose message names the archived state, and nothing is created. """ object_id = usage_key(course_run, "p1") first = user_client.post(criterion_create_url(tag.id), {"object_id": object_id}, format="json") @@ -658,14 +664,44 @@ def test_supplied_group_holding_an_archived_criterion_for_the_same_content_accep archived_criterion = CompetencyCriterion.objects.get(pk=first.data["id"]) archived_criterion.archived = True archived_criterion.save() + criterion_count = CompetencyCriterion.objects.count() response = user_client.post( criterion_create_url(tag.id), {"object_id": object_id, "group_id": first.data["group_id"]}, format="json", ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "previously associated" in str(response.data["group_id"]) + assert "archived" in str(response.data["group_id"]) + assert CompetencyCriterion.objects.count() == criterion_count + archived_criterion.refresh_from_db() + assert archived_criterion.archived is True + + +def test_supplied_different_group_accepts_content_whose_criterion_in_another_group_is_archived( + user_client: APIClient, tag: Tag, course_run: CourseRun, +) -> None: + """ + A criterion for the content in a different active group is accepted although another group holds an archived one. + + The archived criterion blocks only its own group, or the no-``group_id`` path. A different explicit group is a + deliberate second association (ADR-0002). + """ + object_id = usage_key(course_run, "p1") + first = user_client.post(criterion_create_url(tag.id), {"object_id": object_id}, format="json") + assert first.status_code == status.HTTP_201_CREATED + archived_criterion = CompetencyCriterion.objects.get(pk=first.data["id"]) + archived_criterion.archived = True + archived_criterion.save() + other_leaf = _create_leaf_group(tag, course_run) + + response = user_client.post( + criterion_create_url(tag.id), {"object_id": object_id, "group_id": other_leaf.id}, format="json", + ) + assert response.status_code == status.HTTP_201_CREATED new_criterion = CompetencyCriterion.objects.get(pk=response.data["id"]) - assert new_criterion.group_id == archived_criterion.group_id + assert new_criterion.group_id == other_leaf.id assert new_criterion.object_tag_id == archived_criterion.object_tag_id archived_criterion.refresh_from_db() assert archived_criterion.archived is True