Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions src/openedx_learning/applets/cbe/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions src/openedx_learning/applets/cbe/rest_api/paginators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
36 changes: 36 additions & 0 deletions src/openedx_learning/applets/cbe/rest_api/v1/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 5 additions & 0 deletions src/openedx_learning/applets/cbe/rest_api/v1/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,9 @@
views.CompetencyCriteriaGroupDetailView.as_view(),
name="criteria-group-detail",
),
path(
"competencies/<int:tag_id>/blocked-courses/",
views.CompetencyBlockedCoursesView.as_view(),
name="blocked-courses",
),
]
37 changes: 36 additions & 1 deletion src/openedx_learning/applets/cbe/rest_api/v1/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I feel like this description is a little vague; "where a competency can't get a new criterion," could be clarified as to why it can't get a new Criterion? I think the next paragraph kind of touches on it, but I feel like this would be the place to concisely explain the 'why'


``GET api/cbe/v1/competencies/<tag_id>/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)
171 changes: 170 additions & 1 deletion tests/openedx_learning/applets/cbe/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) == []
Loading
Loading