From 6f88a96eec077f5589af6cae3cd989dcec12ba98 Mon Sep 17 00:00:00 2001 From: alezconsultant Date: Tue, 20 Oct 2026 19:04:36 +0400 Subject: [PATCH] feat: add blocked-courses endpoint for a competency Adds GET api/cbe/v1/competencies//blocked-courses/, which lists the courses where a taxonomy ancestor or descendant of the competency already has a criterion, so authoring can gray them out before the create-criterion endpoint rejects them. An optional comma-separated course_keys parameter narrows the list, and the result is paginated. get_blocked_courses() reuses get_courses_with_relative_criteria(), so this read and the write-side check always agree. CompetencyReadPermission is copied verbatim from the criteria-groups endpoint's PR. Related to openedx/openedx-core#796 Co-Authored-By: Claude Opus 5.5 --- src/openedx_learning/applets/cbe/api.py | 36 ++ .../applets/cbe/rest_api/paginators.py | 9 + .../applets/cbe/rest_api/v1/serializers.py | 36 ++ .../applets/cbe/rest_api/v1/urls.py | 5 + .../applets/cbe/rest_api/v1/views.py | 37 +- .../openedx_learning/applets/cbe/test_api.py | 171 ++++++++- .../applets/cbe/test_views.py | 343 ++++++++++++++++++ 7 files changed, 635 insertions(+), 2 deletions(-) diff --git a/src/openedx_learning/applets/cbe/api.py b/src/openedx_learning/applets/cbe/api.py index ac2d51609..85f4f5c22 100644 --- a/src/openedx_learning/applets/cbe/api.py +++ b/src/openedx_learning/applets/cbe/api.py @@ -45,6 +45,7 @@ "bulk_update_competency_criteria", "create_competency_criterion", "create_competency_taxonomy", + "get_blocked_courses", "get_competency_criteria_tree", "get_competency_rule_profiles", "get_courses_with_relative_criteria", @@ -361,6 +362,41 @@ def get_courses_with_relative_criteria( return result +@dataclass +class BlockedCourse: + """A course where a taxonomy relative of a competency already has a live CompetencyCriterion.""" + + course_key: CourseKey + conflicting_tag_id: int + conflicting_tag_value: str + + +def get_blocked_courses(tag: Tag, course_keys: Iterable[CourseKey] | None = None) -> list[BlockedCourse]: + """ + Return the courses where ``tag`` can't get a new criterion, because an ancestor or descendant has one there. + + UNSTABLE: the criteria-authoring family is incomplete, so its group update and archive + behavior may change this function's shape without a deprecation cycle. + + Narrowed to ``course_keys`` when given, and sorted by course key. + """ + relative_ids = ( + set(get_ancestor_tags(tag).values_list("id", flat=True)) + | set(get_descendant_tags(tag).values_list("id", flat=True)) + ) + if not relative_ids: + return [] + conflicts = get_courses_with_relative_criteria(relative_ids) + requested = set(course_keys) if course_keys is not None else None + rows = [] + for course_run, conflicting_tag in conflicts.items(): + # CourseRun.save() always derives course_key, so a saved row has one. + assert course_run.course_key is not None + if requested is None or course_run.course_key in requested: + rows.append(BlockedCourse(course_run.course_key, conflicting_tag.id, conflicting_tag.value)) + return sorted(rows, key=lambda row: str(row.course_key)) + + def _validate_containment(group: CompetencyCriteriaGroup, course: CourseRun) -> None: """ Raise ValidationError (keyed "tag_id") if ``group.tag`` conflicts with an existing criterion. diff --git a/src/openedx_learning/applets/cbe/rest_api/paginators.py b/src/openedx_learning/applets/cbe/rest_api/paginators.py index ea23f67c9..4912bd1e2 100644 --- a/src/openedx_learning/applets/cbe/rest_api/paginators.py +++ b/src/openedx_learning/applets/cbe/rest_api/paginators.py @@ -18,3 +18,12 @@ class CompetencyRuleProfilePagination(DefaultPagination): page_size = 100 max_page_size = 500 + + +class CompetencyBlockedCoursesPagination(DefaultPagination): + """ + Page size for a competency's blocked-courses collection, pinned for the same reason as the rule profiles'. + """ + + page_size = 100 + max_page_size = 500 diff --git a/src/openedx_learning/applets/cbe/rest_api/v1/serializers.py b/src/openedx_learning/applets/cbe/rest_api/v1/serializers.py index 57376ee8f..fa078df54 100644 --- a/src/openedx_learning/applets/cbe/rest_api/v1/serializers.py +++ b/src/openedx_learning/applets/cbe/rest_api/v1/serializers.py @@ -235,3 +235,39 @@ class Meta: "id", "group_id", "object_tag_id", "object_id", "rule_profile_id", "rule_type_override", "rule_payload_override", ] + + +class BlockedCourseSerializer(serializers.Serializer): # pylint: disable=abstract-method + """ + Read-only representation of a BlockedCourse, for the blocked-courses endpoint. + + UNSTABLE: the criteria-authoring family is incomplete, so this shape may change without a + deprecation cycle. + """ + + course_key = serializers.CharField(read_only=True) + conflicting_tag_id = serializers.IntegerField(read_only=True) + conflicting_tag_value = serializers.CharField(read_only=True) + + +class CompetencyBlockedCoursesQueryParamsSerializer(serializers.Serializer): # pylint: disable=abstract-method + """ + Query params for the blocked-courses endpoint: an optional comma-separated ``course_keys`` filter. + """ + + course_keys = serializers.CharField(required=False) + + def validate_course_keys(self, value: str) -> list[CourseKey]: + """Parse the comma-separated course keys, rejecting a filter that names none or an unparseable one.""" + pieces = [piece.strip() for piece in value.split(",") if piece.strip()] + if not pieces: + raise serializers.ValidationError(_("course_keys must name at least one course key.")) + course_keys = [] + for piece in pieces: + try: + course_keys.append(CourseKey.from_string(piece)) + except InvalidKeyError as exc: + raise serializers.ValidationError( + _("'{course_key}' is not a valid course key.").format(course_key=piece) + ) from exc + return course_keys diff --git a/src/openedx_learning/applets/cbe/rest_api/v1/urls.py b/src/openedx_learning/applets/cbe/rest_api/v1/urls.py index e96d754a1..0570fdd5a 100644 --- a/src/openedx_learning/applets/cbe/rest_api/v1/urls.py +++ b/src/openedx_learning/applets/cbe/rest_api/v1/urls.py @@ -32,4 +32,9 @@ views.CompetencyCriteriaGroupDetailView.as_view(), name="criteria-group-detail", ), + path( + "competencies//blocked-courses/", + views.CompetencyBlockedCoursesView.as_view(), + name="blocked-courses", + ), ] diff --git a/src/openedx_learning/applets/cbe/rest_api/v1/views.py b/src/openedx_learning/applets/cbe/rest_api/v1/views.py index d7c3a4d8c..2fcc55f21 100644 --- a/src/openedx_learning/applets/cbe/rest_api/v1/views.py +++ b/src/openedx_learning/applets/cbe/rest_api/v1/views.py @@ -26,15 +26,18 @@ CompetencyCriterionArchivedError, associate_competency_criterion, bulk_update_competency_criteria, + get_blocked_courses, get_competency_criteria_tree, get_competency_rule_profiles, resolve_competency_tag, update_competency_criteria_group, ) from ...models import CompetencyCriteriaGroup, CompetencyCriterion, CompetencyRuleProfile -from ..paginators import CompetencyRuleProfilePagination +from ..paginators import CompetencyBlockedCoursesPagination, CompetencyRuleProfilePagination from .permissions import CompetencyReadPermission, CompetencyRuleProfilePermissions from .serializers import ( + BlockedCourseSerializer, + CompetencyBlockedCoursesQueryParamsSerializer, CompetencyCriteriaGroupSerializer, CompetencyCriteriaGroupUpdateSerializer, CompetencyCriteriaQueryParamsSerializer, @@ -273,3 +276,35 @@ def patch(self, request, tag_id: int, group_id: int): except CompetencyCriteriaGroupArchivedError as exc: raise CompetencyCriteriaConflict(str(exc)) from exc return Response(CompetencyCriteriaGroupSerializer(group).data, status=status.HTTP_200_OK) + + +class CompetencyBlockedCoursesView(generics.GenericAPIView): + """ + GET-only. List the courses where a competency can't get a new criterion, paginated. + + ``GET api/cbe/v1/competencies//blocked-courses/`` returns every course where a + taxonomy ancestor or descendant of the competency already has a live criterion, sorted by + course key; ``course_keys`` (comma-separated) narrows it. The result is a hint for authoring: + the create-criterion endpoint still enforces the rule. 404 if ``tag_id`` isn't a competency + tag, 403 if the caller can't view its taxonomy, 400 for a malformed ``course_keys``. + + UNSTABLE: the criteria-authoring family is incomplete, so this shape may change without a + deprecation cycle. + """ + + authentication_classes = (JwtAuthentication, SessionAuthenticationAllowInactiveUser) + permission_classes = [IsAuthenticated, CompetencyReadPermission] + pagination_class = CompetencyBlockedCoursesPagination + + def get(self, request, tag_id): + """Return one page of the competency's blocked courses.""" + try: + tag = resolve_competency_tag(tag_id) + except Tag.DoesNotExist as exc: + raise NotFound(str(exc)) from exc + self.check_object_permissions(request, tag) + params = CompetencyBlockedCoursesQueryParamsSerializer(data=request.query_params.dict()) + params.is_valid(raise_exception=True) + rows = get_blocked_courses(tag, course_keys=params.validated_data.get("course_keys")) + page = self.paginate_queryset(rows) + return self.get_paginated_response(BlockedCourseSerializer(page, many=True).data) diff --git a/tests/openedx_learning/applets/cbe/test_api.py b/tests/openedx_learning/applets/cbe/test_api.py index 52f844e07..0fd27686b 100644 --- a/tests/openedx_learning/applets/cbe/test_api.py +++ b/tests/openedx_learning/applets/cbe/test_api.py @@ -25,6 +25,7 @@ bulk_update_competency_criteria, create_competency_criterion, create_competency_taxonomy, + get_blocked_courses, get_competency_criteria_tree, get_competency_rule_profiles, get_courses_with_relative_criteria, @@ -34,7 +35,7 @@ update_competency_criteria_group, ) from openedx_learning.applets.cbe import api as cbe_api -from openedx_learning.applets.cbe.api import _create_leaf_group +from openedx_learning.applets.cbe.api import BlockedCourse, _create_leaf_group from openedx_learning.models import ( CompetencyCourseRun, CompetencyCriteriaGroup, @@ -105,6 +106,13 @@ def make_course_run(organization: Organization, course_code: str, run_code: str) return CourseRun.objects.create(catalog_course=catalog_course, run_code=run_code) +def course_key(course_run: CourseRun) -> CourseKey: + """Return `course_run`'s course key, narrowed for mypy; a saved CourseRun always has one.""" + key = course_run.course_key + assert key is not None + return key + + def usage_key(course_run: CourseRun, block_id: str) -> str: """Build a gradeable-subsection-shaped usage key string under `course_run`.""" key = course_run.course_key @@ -2302,3 +2310,164 @@ def test_update_never_reads_or_writes_learner_status( pass assert not [q for q in queries.captured_queries if "studentcompetency" in q["sql"].lower()] + + +# ============================================================================================== +# get_blocked_courses +# ============================================================================================== + + +def make_skill_family(taxonomy: CompetencyTaxonomy) -> tuple[Tag, Tag, Tag, Tag]: + """ + Create "Cognitive Skills" > "Critical Thinking" > "Inference", plus "Analysis" as a sibling of "Inference". + """ + cognitive_skills = make_tag(taxonomy, "Cognitive Skills") + critical_thinking = make_tag(taxonomy, "Critical Thinking", parent=cognitive_skills) + inference = make_tag(taxonomy, "Inference", parent=critical_thinking) + analysis = make_tag(taxonomy, "Analysis", parent=critical_thinking) + return cognitive_skills, critical_thinking, inference, analysis + + +def test_get_blocked_courses_reports_an_ancestor_claim( + competency_taxonomy: CompetencyTaxonomy, course_run: CourseRun, default_rule_profile: CompetencyRuleProfile, +) -> None: + """A course where an ancestor has a criterion is blocked, naming that ancestor.""" + _, critical_thinking, inference, _ = make_skill_family(competency_taxonomy) + make_leaf_criterion(critical_thinking, course_run, default_rule_profile, "p1") + + assert get_blocked_courses(inference) == [ + BlockedCourse(course_key(course_run), critical_thinking.id, "Critical Thinking"), + ] + + +def test_get_blocked_courses_reports_a_descendant_claim( + competency_taxonomy: CompetencyTaxonomy, course_run: CourseRun, default_rule_profile: CompetencyRuleProfile, +) -> None: + """A course where a descendant has a criterion is blocked, naming that descendant.""" + _, critical_thinking, inference, _ = make_skill_family(competency_taxonomy) + make_leaf_criterion(inference, course_run, default_rule_profile, "p1") + + assert get_blocked_courses(critical_thinking) == [ + BlockedCourse(course_key(course_run), inference.id, "Inference"), + ] + + +def test_get_blocked_courses_reports_a_grandparent_claim( + competency_taxonomy: CompetencyTaxonomy, course_run: CourseRun, default_rule_profile: CompetencyRuleProfile, +) -> None: + """The block reaches past the immediate parent to a grandparent.""" + cognitive_skills, _, inference, _ = make_skill_family(competency_taxonomy) + make_leaf_criterion(cognitive_skills, course_run, default_rule_profile, "p1") + + assert get_blocked_courses(inference) == [ + BlockedCourse(course_key(course_run), cognitive_skills.id, "Cognitive Skills"), + ] + + +def test_get_blocked_courses_reports_a_grandchild_claim( + competency_taxonomy: CompetencyTaxonomy, course_run: CourseRun, default_rule_profile: CompetencyRuleProfile, +) -> None: + """The block reaches past the immediate child to a grandchild.""" + cognitive_skills, _, inference, _ = make_skill_family(competency_taxonomy) + make_leaf_criterion(inference, course_run, default_rule_profile, "p1") + + assert get_blocked_courses(cognitive_skills) == [ + BlockedCourse(course_key(course_run), inference.id, "Inference"), + ] + + +def test_get_blocked_courses_ignores_a_sibling_claim( + competency_taxonomy: CompetencyTaxonomy, course_run: CourseRun, default_rule_profile: CompetencyRuleProfile, +) -> None: + """A sibling is neither ancestor nor descendant, so its criterion blocks nothing.""" + _, _, inference, analysis = make_skill_family(competency_taxonomy) + make_leaf_criterion(analysis, course_run, default_rule_profile, "p1") + + assert not get_blocked_courses(inference) + + +def test_get_blocked_courses_ignores_a_course_with_no_relative_claim( + competency_taxonomy: CompetencyTaxonomy, + course_run: CourseRun, + organization: Organization, + default_rule_profile: CompetencyRuleProfile, +) -> None: + """Only the course holding a relative's criterion is reported.""" + _, critical_thinking, inference, _ = make_skill_family(competency_taxonomy) + make_course_run(organization, "Python200", "Fall2026") + make_leaf_criterion(critical_thinking, course_run, default_rule_profile, "p1") + + assert [row.course_key for row in get_blocked_courses(inference)] == [course_run.course_key] + + +def test_get_blocked_courses_is_empty_when_no_relative_has_a_claim( + competency_taxonomy: CompetencyTaxonomy, +) -> None: + """A tag whose ancestors and descendants have no criteria anywhere blocks no course.""" + _, critical_thinking, _, _ = make_skill_family(competency_taxonomy) + + assert get_blocked_courses(critical_thinking) == [] + + +def test_get_blocked_courses_is_empty_for_a_tag_with_no_relatives(tag: Tag) -> None: + """A root tag with no children has no relatives to conflict with.""" + assert get_blocked_courses(tag) == [] + + +def test_get_blocked_courses_reports_one_row_per_course_naming_the_lowest_id_relative( + competency_taxonomy: CompetencyTaxonomy, course_run: CourseRun, default_rule_profile: CompetencyRuleProfile, +) -> None: + """A course claimed by two relatives is reported once, naming the lower-id relative.""" + cognitive_skills, critical_thinking, inference, _ = make_skill_family(competency_taxonomy) + assert cognitive_skills.id < critical_thinking.id + make_leaf_criterion(critical_thinking, course_run, default_rule_profile, "p1") + make_leaf_criterion(cognitive_skills, course_run, default_rule_profile, "p2") + + assert get_blocked_courses(inference) == [ + BlockedCourse(course_key(course_run), cognitive_skills.id, "Cognitive Skills"), + ] + + +def test_get_blocked_courses_narrows_to_the_requested_course_keys( + competency_taxonomy: CompetencyTaxonomy, + course_run: CourseRun, + organization: Organization, + default_rule_profile: CompetencyRuleProfile, +) -> None: + """Only the requested courses come back, even when others are blocked too.""" + cognitive_skills, critical_thinking, inference, _ = make_skill_family(competency_taxonomy) + course_y = make_course_run(organization, "Python200", "Fall2026") + course_z = make_course_run(organization, "Python300", "Fall2026") + make_leaf_criterion(critical_thinking, course_run, default_rule_profile, "p1") + make_leaf_criterion(cognitive_skills, course_y, default_rule_profile, "p1") + make_leaf_criterion(critical_thinking, course_z, default_rule_profile, "p1") + + rows = get_blocked_courses(inference, course_keys=[course_key(course_run), course_key(course_y)]) + + assert {row.course_key for row in rows} == {course_run.course_key, course_y.course_key} + + +def test_get_blocked_courses_sorts_rows_by_course_key( + competency_taxonomy: CompetencyTaxonomy, organization: Organization, default_rule_profile: CompetencyRuleProfile, +) -> None: + """Rows come back in course key order, not in the order the courses were created.""" + _, critical_thinking, inference, _ = make_skill_family(competency_taxonomy) + for course_code in ("C300", "A100", "B200"): + course = make_course_run(organization, course_code, "Run1") + make_leaf_criterion(critical_thinking, course, default_rule_profile, "p1") + + keys = [str(row.course_key) for row in get_blocked_courses(inference)] + + assert keys == sorted(keys) + assert len(keys) == 3 + + +def test_get_blocked_courses_ignores_an_archived_claim( + competency_taxonomy: CompetencyTaxonomy, course_run: CourseRun, default_rule_profile: CompetencyRuleProfile, +) -> None: + """An archived criterion on a relative blocks nothing.""" + _, critical_thinking, inference, _ = make_skill_family(competency_taxonomy) + criterion = make_leaf_criterion(critical_thinking, course_run, default_rule_profile, "p1") + CompetencyCriterion.objects.filter(pk=criterion.pk).update(archived=True) + + assert get_blocked_courses(inference) == [] diff --git a/tests/openedx_learning/applets/cbe/test_views.py b/tests/openedx_learning/applets/cbe/test_views.py index be957f857..dc9c0a277 100644 --- a/tests/openedx_learning/applets/cbe/test_views.py +++ b/tests/openedx_learning/applets/cbe/test_views.py @@ -1993,3 +1993,346 @@ def test_group_detail_offers_only_patch( response = getattr(user_client, method)(group_detail_url(tag.id, leaf.id), {}, format="json") assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED + + +# ============================================================================================== +# CompetencyBlockedCoursesView +# ============================================================================================== + + +def blocked_courses_url(tag_id: int, **params: str) -> str: + """Return the blocked-courses endpoint's path for `tag_id`, with `params` as its query string.""" + url = reverse("cbe:blocked-courses", kwargs={"tag_id": tag_id}) + return f"{url}?{urlencode(params)}" if params else url + + +def make_leaf_criterion( + tag: Tag, course_run: CourseRun, default_rule_profile: CompetencyRuleProfile, block_id: str, +) -> CompetencyCriterion: + """Create a leaf group and one live CompetencyCriterion for `tag` in `course_run`.""" + leaf = _create_leaf_group(tag, course_run) + object_tag = ObjectTag.objects.create(object_id=usage_key(course_run, block_id), taxonomy=tag.taxonomy, tag=tag) + return CompetencyCriterion.objects.create(group=leaf, object_tag=object_tag, rule_profile=default_rule_profile) + + +def collect_pages(client: APIClient, url: str) -> list[dict]: + """Follow `next` links from `url` and return every row across all pages.""" + rows: list[dict] = [] + next_url: str | None = url + while next_url: + response = client.get(next_url) + assert response.status_code == status.HTTP_200_OK + rows.extend(response.data["results"]) + next_url = response.data["next"] + return rows + + +def make_skill_family(taxonomy: CompetencyTaxonomy) -> tuple[Tag, Tag, Tag, Tag]: + """ + Create "Cognitive Skills" > "Critical Thinking" > "Inference", plus "Analysis" as a sibling of "Inference". + """ + cognitive_skills = make_tag(taxonomy, "Cognitive Skills") + critical_thinking = make_tag(taxonomy, "Critical Thinking", parent=cognitive_skills) + inference = make_tag(taxonomy, "Inference", parent=critical_thinking) + analysis = make_tag(taxonomy, "Analysis", parent=critical_thinking) + return cognitive_skills, critical_thinking, inference, analysis + + +def test_blocked_courses_reports_an_ancestor_claim( + user_client: APIClient, + competency_taxonomy: CompetencyTaxonomy, + course_run: CourseRun, + default_rule_profile: CompetencyRuleProfile, +) -> None: + """A course where an ancestor has a criterion is reported, with the conflicting tag's id and value.""" + _, critical_thinking, inference, _ = make_skill_family(competency_taxonomy) + make_leaf_criterion(critical_thinking, course_run, default_rule_profile, "p1") + + response = user_client.get(blocked_courses_url(inference.id)) + + assert response.status_code == status.HTTP_200_OK + assert response.data["results"] == [{ + "course_key": str(course_run.course_key), + "conflicting_tag_id": critical_thinking.id, + "conflicting_tag_value": "Critical Thinking", + }] + + +def test_blocked_courses_reports_a_descendant_claim( + user_client: APIClient, + competency_taxonomy: CompetencyTaxonomy, + course_run: CourseRun, + default_rule_profile: CompetencyRuleProfile, +) -> None: + """A course where a descendant has a criterion is reported, naming that descendant.""" + _, critical_thinking, inference, _ = make_skill_family(competency_taxonomy) + make_leaf_criterion(inference, course_run, default_rule_profile, "p1") + + response = user_client.get(blocked_courses_url(critical_thinking.id)) + + assert response.status_code == status.HTTP_200_OK + assert [row["course_key"] for row in response.data["results"]] == [str(course_run.course_key)] + assert response.data["results"][0]["conflicting_tag_value"] == "Inference" + + +def test_blocked_courses_reports_a_grandparent_claim( + user_client: APIClient, + competency_taxonomy: CompetencyTaxonomy, + course_run: CourseRun, + default_rule_profile: CompetencyRuleProfile, +) -> None: + """The block reaches a grandparent, and the grandparent is the tag reported.""" + cognitive_skills, _, inference, _ = make_skill_family(competency_taxonomy) + make_leaf_criterion(cognitive_skills, course_run, default_rule_profile, "p1") + + response = user_client.get(blocked_courses_url(inference.id)) + + assert response.status_code == status.HTTP_200_OK + assert [row["course_key"] for row in response.data["results"]] == [str(course_run.course_key)] + assert response.data["results"][0]["conflicting_tag_value"] == "Cognitive Skills" + + +def test_blocked_courses_reports_a_grandchild_claim( + user_client: APIClient, + competency_taxonomy: CompetencyTaxonomy, + course_run: CourseRun, + default_rule_profile: CompetencyRuleProfile, +) -> None: + """The block reaches a grandchild, and the grandchild is the tag reported.""" + cognitive_skills, _, inference, _ = make_skill_family(competency_taxonomy) + make_leaf_criterion(inference, course_run, default_rule_profile, "p1") + + response = user_client.get(blocked_courses_url(cognitive_skills.id)) + + assert response.status_code == status.HTTP_200_OK + assert [row["course_key"] for row in response.data["results"]] == [str(course_run.course_key)] + assert response.data["results"][0]["conflicting_tag_value"] == "Inference" + + +def test_blocked_courses_ignores_a_sibling_claim( + user_client: APIClient, + competency_taxonomy: CompetencyTaxonomy, + course_run: CourseRun, + default_rule_profile: CompetencyRuleProfile, +) -> None: + """A course claimed only by a sibling tag is not reported.""" + _, _, inference, analysis = make_skill_family(competency_taxonomy) + make_leaf_criterion(analysis, course_run, default_rule_profile, "p1") + + response = user_client.get(blocked_courses_url(inference.id)) + + assert response.status_code == status.HTTP_200_OK + assert response.data["results"] == [] + + +def test_blocked_courses_ignores_an_unaffected_course( + user_client: APIClient, + competency_taxonomy: CompetencyTaxonomy, + course_run: CourseRun, + organization: Organization, + default_rule_profile: CompetencyRuleProfile, +) -> None: + """A course with no relative's criterion is not reported, even when another course is blocked.""" + _, critical_thinking, inference, _ = make_skill_family(competency_taxonomy) + course_y = make_course_run(organization, "Python200", "Fall2026") + make_leaf_criterion(critical_thinking, course_run, default_rule_profile, "p1") + + response = user_client.get(blocked_courses_url(inference.id)) + + assert response.status_code == status.HTTP_200_OK + returned_keys = [row["course_key"] for row in response.data["results"]] + assert str(course_y.course_key) not in returned_keys + assert returned_keys == [str(course_run.course_key)] + + +def test_blocked_courses_narrows_to_requested_course_keys( + user_client: APIClient, + competency_taxonomy: CompetencyTaxonomy, + course_run: CourseRun, + organization: Organization, + default_rule_profile: CompetencyRuleProfile, +) -> None: + """Only the requested courses come back, though a third course is blocked too.""" + cognitive_skills, critical_thinking, inference, _ = make_skill_family(competency_taxonomy) + course_y = make_course_run(organization, "Python200", "Fall2026") + course_z = make_course_run(organization, "Python300", "Fall2026") + make_leaf_criterion(critical_thinking, course_run, default_rule_profile, "p1") + make_leaf_criterion(cognitive_skills, course_y, default_rule_profile, "p1") + make_leaf_criterion(critical_thinking, course_z, default_rule_profile, "p1") + + response = user_client.get( + blocked_courses_url(inference.id, course_keys=f"{course_run.course_key},{course_y.course_key}"), + ) + + assert response.status_code == status.HTTP_200_OK + assert {row["course_key"] for row in response.data["results"]} == { + str(course_run.course_key), str(course_y.course_key), + } + + +def test_blocked_courses_is_an_empty_200_when_nothing_is_claimed( + user_client: APIClient, competency_taxonomy: CompetencyTaxonomy, +) -> None: + """No relative has a criterion anywhere: an empty collection, not a failure.""" + _, _, inference, _ = make_skill_family(competency_taxonomy) + + response = user_client.get(blocked_courses_url(inference.id)) + + assert response.status_code == status.HTTP_200_OK + assert response.data["count"] == 0 + assert response.data["results"] == [] + + +def test_blocked_courses_paginates_without_repeats_or_gaps( + user_client: APIClient, + competency_taxonomy: CompetencyTaxonomy, + organization: Organization, + default_rule_profile: CompetencyRuleProfile, +) -> None: + """Three blocked courses at two per page arrive across two pages, each exactly once.""" + _, critical_thinking, inference, _ = make_skill_family(competency_taxonomy) + expected_keys = set() + for course_code in ("A100", "B200", "C300"): + course = make_course_run(organization, course_code, "Run1") + make_leaf_criterion(critical_thinking, course, default_rule_profile, "p1") + expected_keys.add(str(course.course_key)) + url = blocked_courses_url(inference.id, page_size="2") + + first_page = user_client.get(url) + rows = collect_pages(user_client, url) + + assert first_page.data["count"] == 3 + assert len(first_page.data["results"]) == 2 + assert first_page.data["next"] is not None + keys = [row["course_key"] for row in rows] + assert len(keys) == len(set(keys)) == 3 + assert set(keys) == expected_keys + + +def test_blocked_courses_filters_before_paginating( + user_client: APIClient, + competency_taxonomy: CompetencyTaxonomy, + organization: Organization, + default_rule_profile: CompetencyRuleProfile, +) -> None: + """A narrowed result that spans two pages holds exactly the requested courses, and nothing outside them.""" + _, critical_thinking, inference, _ = make_skill_family(competency_taxonomy) + requested_keys = [] + for course_code in ("A100", "B200", "C300", "D400"): + course = make_course_run(organization, course_code, "Run1") + make_leaf_criterion(critical_thinking, course, default_rule_profile, "p1") + requested_keys.append(str(course.course_key)) + outside_key = requested_keys.pop(1) + url = blocked_courses_url(inference.id, course_keys=",".join(requested_keys), page_size="2") + + first_page = user_client.get(url) + rows = collect_pages(user_client, url) + + assert first_page.data["count"] == 3 + assert len(first_page.data["results"]) == 2 + keys = [row["course_key"] for row in rows] + assert len(keys) == len(set(keys)) == 3 + assert set(keys) == set(requested_keys) + assert outside_key not in keys + + +def test_blocked_courses_unknown_tag_404s(user_client: APIClient) -> None: + """A tag_id that doesn't resolve is reported as not found.""" + response = user_client.get(blocked_courses_url(999999)) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + +def test_blocked_courses_tag_outside_a_competency_taxonomy_404s(user_client: APIClient) -> None: + """A real Tag that isn't on a CompetencyTaxonomy is reported as not found.""" + plain_taxonomy = Taxonomy.objects.create(name="Plain Tags", export_id="plain-v1") + plain_tag = Tag.objects.create(taxonomy=plain_taxonomy, value="Not A Competency") + + response = user_client.get(blocked_courses_url(plain_tag.id)) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + +def test_blocked_courses_without_view_access_403s( + api_client: APIClient, + user, + competency_taxonomy: CompetencyTaxonomy, + course_run: CourseRun, + default_rule_profile: CompetencyRuleProfile, +) -> None: + """A non-staff caller may not read blocked courses for a competency on a disabled taxonomy.""" + _, critical_thinking, inference, _ = make_skill_family(competency_taxonomy) + make_leaf_criterion(critical_thinking, course_run, default_rule_profile, "p1") + competency_taxonomy.enabled = False + competency_taxonomy.save() + api_client.force_authenticate(user=user) + + response = api_client.get(blocked_courses_url(inference.id)) + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert "results" not in response.data + + +def test_blocked_courses_malformed_course_key_400s( + user_client: APIClient, competency_taxonomy: CompetencyTaxonomy, +) -> None: + """An unparseable course key is rejected, and the error names it.""" + _, _, inference, _ = make_skill_family(competency_taxonomy) + + response = user_client.get(blocked_courses_url(inference.id, course_keys="not-a-key")) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "not-a-key" in str(response.data["course_keys"]) + + +@pytest.mark.parametrize("course_keys", ["", ",,"]) +def test_blocked_courses_empty_course_keys_400s( + user_client: APIClient, competency_taxonomy: CompetencyTaxonomy, course_keys: str, +) -> None: + """A course_keys filter that names no course is rejected rather than ignored.""" + _, _, inference, _ = make_skill_family(competency_taxonomy) + + response = user_client.get(blocked_courses_url(inference.id, course_keys=course_keys)) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "course_keys" in response.data + + +def test_blocked_courses_strips_whitespace_around_course_keys( + user_client: APIClient, + competency_taxonomy: CompetencyTaxonomy, + course_run: CourseRun, + organization: Organization, + default_rule_profile: CompetencyRuleProfile, +) -> None: + """Spaces after the commas don't make a key unparseable.""" + _, critical_thinking, inference, _ = make_skill_family(competency_taxonomy) + course_y = make_course_run(organization, "Python200", "Fall2026") + make_leaf_criterion(critical_thinking, course_run, default_rule_profile, "p1") + make_leaf_criterion(critical_thinking, course_y, default_rule_profile, "p1") + + response = user_client.get( + blocked_courses_url(inference.id, course_keys=f"{course_run.course_key}, {course_y.course_key}"), + ) + + assert response.status_code == status.HTTP_200_OK + assert {row["course_key"] for row in response.data["results"]} == { + str(course_run.course_key), str(course_y.course_key), + } + + +def test_blocked_courses_ignores_an_archived_claim( + user_client: APIClient, + competency_taxonomy: CompetencyTaxonomy, + course_run: CourseRun, + default_rule_profile: CompetencyRuleProfile, +) -> None: + """A relative's archived criterion doesn't block the course.""" + _, critical_thinking, inference, _ = make_skill_family(competency_taxonomy) + criterion = make_leaf_criterion(critical_thinking, course_run, default_rule_profile, "p1") + CompetencyCriterion.objects.filter(pk=criterion.pk).update(archived=True) + + response = user_client.get(blocked_courses_url(inference.id)) + + assert response.status_code == status.HTTP_200_OK + assert response.data["results"] == []