Skip to content
Merged
13 changes: 13 additions & 0 deletions docs/openedx_learning/decisions/0002-competency-criteria-model.rst
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,14 @@ Decision
- Points to one ``oel_tagging_objecttag`` association.
- Uses one ``CompetencyRuleProfile`` by default, with optional per-criterion overrides.

No ancestor or descendant of a given competency tag may hold live criteria in the same course
as the given competency tag. CBE takes as given that a learner who demonstrates every
sub-competency has demonstrated the parent competency. If a course could hold authored
criteria at more than one level of the same competency hierarchy, it would be undefined
whether that given or the authored criteria decide the parent's status, and which takes
precedence. There is also no use case for it: nobody authors criteria for a competency and
its ancestors in the same course, because doing so only adds complexity and confusion.

This new database table will have the following columns:

1. ``id``: unique primary key
Expand Down Expand Up @@ -490,3 +498,8 @@ Changelog
supersedes the ordered, short-circuited evaluation the earlier text described. Decision 6's update flow now continues past the root group to the
competency's own status row and up through parent competencies, and states what a
competency with no criteria tree of its own receives.

2026-11-06:

* Decision 4 states that no ancestor or descendant of a competency tag may hold live criteria
in the same course as that tag, and why.
80 changes: 69 additions & 11 deletions src/openedx_learning/applets/cbe/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
from .statuses import raise_status

__all__ = [
"BlockedCourse",
"CompetencyCriteriaGroupArchivedError",
"CompetencyCriteriaTree",
"CompetencyCriterionArchivedError",
Expand All @@ -47,9 +48,10 @@
"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",
"get_courses_with_live_criteria",
"is_competency_taxonomy",
"record_graded_object_statuses",
"resolve_competency_tag",
Expand Down Expand Up @@ -336,14 +338,18 @@ def resolve_supplied_leaf_group(group_id: int, tag: Tag, course_run: CourseRun)
return group


def get_courses_with_relative_criteria(
tag_ids: Iterable[int], course: CourseRun | None = None,
def get_courses_with_live_criteria(
tag_ids: Iterable[int], course: CourseRun | None = None, *, course_keys: Iterable[CourseKey] | None = None,
) -> dict[CourseRun, Tag]:
"""
Return every course already blocked for one of ``tag_ids`` by an existing CompetencyCriterion.
Return every course where one of ``tag_ids`` has a live CompetencyCriterion.

UNSTABLE: the criteria-authoring family is incomplete, so its group update and archive
behavior may change this function's shape without a deprecation cycle.

Maps each conflicting CourseRun to the lowest-id tag (of ``tag_ids``) with a criterion there,
narrowed to ``course`` when given. Archived criteria and criteria under an archived group are ignored.
narrowed by ``course`` and by ``course_keys``, each when given. Archived criteria and criteria
under an archived group are ignored.
"""
groups: QuerySet[CompetencyCriteriaGroup, CompetencyCriteriaGroup] = CompetencyCriteriaGroup.objects.filter(
tag_id__in=tag_ids,
Expand All @@ -356,6 +362,8 @@ def get_courses_with_relative_criteria(
)
if course is not None:
groups = groups.filter(parent__course=course)
if course_keys is not None:
groups = groups.filter(parent__course__course_key__in=list(course_keys))
result: dict[CourseRun, Tag] = {}
for group in groups.select_related("tag", "parent__course").distinct().order_by("tag_id"):
assert group.parent is not None, f"CompetencyCriteriaGroup {group.id} has no parent"
Expand All @@ -364,6 +372,59 @@ def get_courses_with_relative_criteria(
return result


@dataclass(frozen=True, kw_only=True, slots=True)
class BlockedCourse:
"""
A course where a taxonomy ancestor or descendant of a competency already has a live CompetencyCriterion.

UNSTABLE: the criteria-authoring family is incomplete, so this shape may change without a
deprecation cycle.
"""

course_key: CourseKey
conflicting_tag_id: int
conflicting_tag_value: str


def _get_ancestor_and_descendant_tag_ids(tag: Tag) -> set[int]:
"""
Return the ids of every taxonomy ancestor and descendant of ``tag``.

The blocked-courses read and the write-time containment check both use this, so they cannot disagree.
"""
return (
set(get_ancestor_tags(tag).values_list("id", flat=True))
| set(get_descendant_tags(tag).values_list("id", flat=True))
)


def get_blocked_courses(tag: Tag, course_keys: Iterable[CourseKey] | None = None) -> list[BlockedCourse]:
"""
Return the courses where a taxonomy ancestor or descendant of ``tag`` already has a live criterion.

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. Results are sorted by course key. When several ancestors
or descendants have a criterion in one course, the one with the lowest id is named. Criteria on
``tag`` itself never block a course. Requested keys that match no blocked course are dropped.
"""
ancestor_and_descendant_ids = _get_ancestor_and_descendant_tag_ids(tag)
if not ancestor_and_descendant_ids:
return []
conflicts = get_courses_with_live_criteria(ancestor_and_descendant_ids, course_keys=course_keys)
rows = []
for course_run, conflicting_tag in conflicts.items():
# Typed optional, but save() always sets it.
assert course_run.course_key is not None, f"CourseRun {course_run.id} has no course_key"
rows.append(BlockedCourse(
course_key=course_run.course_key,
conflicting_tag_id=conflicting_tag.id,
conflicting_tag_value=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 All @@ -372,13 +433,10 @@ def _validate_containment(group: CompetencyCriteriaGroup, course: CourseRun) ->
CompetencyCriterion in ``course``.
"""
tag = group.tag
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:
ancestor_and_descendant_ids = _get_ancestor_and_descendant_tag_ids(tag)
if not ancestor_and_descendant_ids:
return
conflicting_tag = get_courses_with_relative_criteria(relative_ids, course=course).get(course)
conflicting_tag = get_courses_with_live_criteria(ancestor_and_descendant_ids, course=course).get(course)
if conflicting_tag is not None:
raise ValidationError({
"tag_id": _(
Expand Down
13 changes: 13 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,16 @@ class CompetencyRuleProfilePagination(DefaultPagination):

page_size = 100
max_page_size = 500


class CompetencyBlockedCoursesPagination(DefaultPagination):
"""
Page size for a competency's blocked-courses collection.

Pinned here rather than inherited from the consuming project's DEFAULT_PAGINATION_CLASS: this
is a published library, so the page size is part of its REST contract instead of varying by
deployment.
"""

page_size = 100
max_page_size = 500
43 changes: 37 additions & 6 deletions src/openedx_learning/applets/cbe/rest_api/v1/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from django.utils.translation import gettext_lazy as _
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey, UsageKey
from opaque_keys.edx.locator import CourseLocator
from rest_framework import serializers

from ...models import CompetencyCriteriaGroup, CompetencyCriterion, CompetencyRuleProfile, LogicOperator
Expand Down Expand Up @@ -177,17 +178,18 @@ def validate(self, attrs: dict) -> dict:


class CompetencyCriteriaQueryParamsSerializer(serializers.Serializer):
"""Query params for the criteria-tree read endpoint."""
"""Query params shared by the criteria-tree and blocked-courses read endpoints."""

course_keys = serializers.CharField(required=False, allow_blank=True)

def validate_course_keys(self, value: str) -> list[CourseKey]:
"""
Parse a comma-separated course_keys value into a deduplicated list of CourseKey.
Parse a comma-separated course_keys value into a deduplicated list of plain course keys.

Stray separators (trailing, doubled, or all-commas) are ignored, not rejected. Raises
a validation error if more than MAX_COURSE_KEYS entries remain after splitting, checked
before dedup so repeating a key can't dodge the cap, or if any entry isn't a valid key.
Branch and version are stripped before dedup, and stray separators (trailing, doubled, or
all-commas) are ignored. Raises a validation error if more than MAX_COURSE_KEYS entries
remain after splitting (counted before dedup, so repeats can't dodge the cap) or if an
entry isn't a course key, library keys included.
"""
entries = [entry for entry in (entry.strip() for entry in value.split(",")) if entry]
if len(entries) > MAX_COURSE_KEYS:
Expand All @@ -197,11 +199,19 @@ def validate_course_keys(self, value: str) -> list[CourseKey]:
parsed = []
for entry in entries:
try:
parsed.append(CourseKey.from_string(entry))
key = CourseKey.from_string(entry)
if not isinstance(key, CourseLocator):
raise InvalidKeyError(type(key), entry)
# A course key may carry a branch (draft or published copy) and a version (one saved
# snapshot). The database field stores only the plain key, so we drop both here to
# match what it holds. A version-only key has no org, so it can't be turned into a
# plain key and is rejected.
key = key.for_branch(None)
except InvalidKeyError as exc:
raise serializers.ValidationError(
_("'%(entry)s' is not a valid course key.") % {"entry": entry}
) from exc
parsed.append(key)
return list(dict.fromkeys(parsed))

def create(self, validated_data):
Expand Down Expand Up @@ -235,3 +245,24 @@ class Meta:
"id", "group_id", "object_tag_id", "object_id",
"rule_profile_id", "rule_type_override", "rule_payload_override",
]


class BlockedCourseSerializer(serializers.Serializer):
"""
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)

def create(self, validated_data):
"""Not supported: this serializer is read-only."""
raise NotImplementedError

def update(self, instance, validated_data):
"""Not supported: this serializer is read-only."""
raise NotImplementedError
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",
),
]
45 changes: 43 additions & 2 deletions 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,17 @@
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,
CompetencyCriteriaGroupSerializer,
CompetencyCriteriaGroupUpdateSerializer,
CompetencyCriteriaQueryParamsSerializer,
Expand Down Expand Up @@ -192,7 +194,8 @@ class CompetencyCriteriaTreeView(generics.GenericAPIView):
Omitting the parameter returns every reachable group. Supplying it returns the groups above
any course plus the groups of the named runs, ordered with the groups above any course first
and then by the order requested; keys that match no run are ignored, and an empty value
returns only the groups above any course. The parameter may be supplied once.
returns only the groups above any course. The parameter may be supplied once. A key's branch
and version are ignored, and a version-only or library key is a 400.

``criteria_count`` is the number of criteria returned. ``total_criteria_count`` is the number
under every reachable group regardless of ``course_keys``, so a client scoped down to zero
Expand Down Expand Up @@ -273,3 +276,41 @@ 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.

Along any ancestor-descendant line of the taxonomy, a course may hold live criteria for one
competency only. So once an ancestor or descendant of this competency has a live criterion in a
course, this competency can't get one in it.

``GET api/cbe/v1/competencies/<tag_id>/blocked-courses/`` returns those courses sorted by
course key. ``course_keys`` (comma-separated, supplied once, at most 100) narrows it; a blank
value returns an empty page. A key's branch and version are ignored, and each row carries the
plain key. The result is an authoring hint: the create-criterion endpoint still enforces the
rule.

UNSTABLE: the criteria-authoring family is incomplete, so this response 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)
if len(request.query_params.getlist("course_keys")) > 1:
raise DRFValidationError({"course_keys": _("Supply course_keys once, as a comma-separated list.")})
query_params = CompetencyCriteriaQueryParamsSerializer(data=request.query_params.dict())
query_params.is_valid(raise_exception=True)
rows = get_blocked_courses(tag, course_keys=query_params.validated_data.get("course_keys"))
page = self.paginate_queryset(rows)
return self.get_paginated_response(BlockedCourseSerializer(page, many=True).data)
Loading
Loading