diff --git a/src/openedx_learning/applets/cbe/api.py b/src/openedx_learning/applets/cbe/api.py index 29205bb0d..4aab2d77f 100644 --- a/src/openedx_learning/applets/cbe/api.py +++ b/src/openedx_learning/applets/cbe/api.py @@ -292,21 +292,39 @@ 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. 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 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( + # 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. 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, 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 +341,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 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: @@ -333,6 +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.")}) + 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 @@ -466,7 +494,11 @@ 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 a criterion, active or archived, already associates ``object_id`` with ``tag``. + + 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. """ @@ -476,18 +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 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 existing 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( @@ -541,12 +589,20 @@ 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 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`` 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 52f844e07..d1a5ab9aa 100644 --- a/tests/openedx_learning/applets/cbe/test_api.py +++ b/tests/openedx_learning/applets/cbe/test_api.py @@ -359,6 +359,59 @@ 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") + + 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).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 + + # ============================================================================================== # resolve_supplied_leaf_group # ============================================================================================== @@ -415,6 +468,51 @@ 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_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: + """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 +647,147 @@ 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. + + 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() + 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_archived_criterion_blocks_re_association_while_a_sibling_keeps_the_group_alive( + tag: Tag, course_run: CourseRun +) -> None: + """ + 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. 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")) + 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() + + with pytest.raises(ValidationError) as exc_info: + associate_competency_criterion(tag_id=tag.id, object_id=usage_key(course_run, "p1")) + + 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() + + 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 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 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) + 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 "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 + + +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 be957f857..48fbf4963 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") @@ -553,6 +553,160 @@ 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 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() + + 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_rejected_as_archived( + user_client: APIClient, tag: Tag, course_run: CourseRun, +) -> None: + """ + 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") + 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() + 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_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_is_rejected_as_archived( + user_client: APIClient, tag: Tag, course_run: CourseRun, +) -> None: + """ + 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`` 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") + assert first.status_code == status.HTTP_201_CREATED + 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 == 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 + + 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: