Skip to content
Merged
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
60 changes: 60 additions & 0 deletions src/openedx_learning/applets/cbe/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from __future__ import annotations

import logging
from dataclasses import dataclass
from datetime import datetime, timezone
from decimal import Decimal
from typing import Any, Sequence
Expand Down Expand Up @@ -35,12 +36,14 @@
from .rule_payloads import evaluate_rule

__all__ = [
"CompetencyCriteriaTree",
"CompetencyCriterionArchivedError",
"GradedObjectScore",
"associate_competency_criterion",
"bulk_update_competency_criteria",
"create_competency_criterion",
"create_competency_taxonomy",
"get_competency_criteria_tree",
"get_competency_rule_profiles",
"is_competency_taxonomy",
"record_graded_object_statuses",
Expand Down Expand Up @@ -143,6 +146,63 @@ def resolve_competency_tag(tag_id: int) -> Tag:
return tag


@dataclass(frozen=True, kw_only=True, slots=True)
class CompetencyCriteriaTree:
"""
One competency's non-archived CompetencyCriteriaGroup tree and CompetencyCriterion leaves.

UNSTABLE: the criteria-authoring family is incomplete, so this shape may change without a
deprecation cycle. ``groups`` and ``criteria`` are flat lists: each group carries its
``parent_id`` and each criterion its ``group_id``, and both always name a group in ``groups``.
"""

groups: list[CompetencyCriteriaGroup]
criteria: list[CompetencyCriterion]


def get_competency_criteria_tree(tag: Tag) -> CompetencyCriteriaTree:
"""
Return the non-archived CompetencyCriteriaGroup tree and CompetencyCriterion leaves for ``tag``.

A group is returned only if it and every ancestor up to its root are non-archived, and a
criterion only if it and its group are returned, so the result always forms one tree: no
returned ``parent_id`` or ``group_id`` names a row that was left out. Groups come back
root first, then ordered by ``(parent_id, ordering, id)``; criteria by ``(group_id, id)``.

UNSTABLE: the criteria-authoring family is incomplete, so its group update and archive
behavior may change this function's shape without a deprecation cycle.
"""
groups = list(
CompetencyCriteriaGroup.objects.filter(tag=tag, archived=False)
.select_related("course")
.order_by(F("parent_id").asc(nulls_first=True), "ordering", "id")
)

# Walk down from the roots instead of relying on row order: ordering by parent_id does not
# guarantee a parent comes before its child once groups can be moved, since a parent may
# then have a larger id than its child.
children_by_parent_id: dict[int | None, list[int]] = {}
for group in groups:
children_by_parent_id.setdefault(group.parent_id, []).append(group.id)
kept_group_ids: set[int] = set()
pending = list(children_by_parent_id.get(None, []))
while pending:
group_id = pending.pop()
kept_group_ids.add(group_id)
pending.extend(children_by_parent_id.get(group_id, []))

criteria = list(
CompetencyCriterion.objects.filter(group_id__in=kept_group_ids, archived=False)
.select_related("object_tag")
.order_by("group_id", "id")
)

return CompetencyCriteriaTree(
groups=[group for group in groups if group.id in kept_group_ids],
criteria=criteria,
)


def _create_leaf_group(
tag: Tag,
course_run: CourseRun,
Expand Down
17 changes: 17 additions & 0 deletions src/openedx_learning/applets/cbe/rest_api/v1/permissions.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""
Permissions for the CBE REST API, v1.
"""
from rest_framework import permissions
from rest_framework.permissions import DjangoObjectPermissions


Expand All @@ -17,3 +18,19 @@ class CompetencyRuleProfilePermissions(DjangoObjectPermissions):
"OPTIONS": [],
"HEAD": ["%(app_label)s.view_%(model_name)s"],
}


class CompetencyReadPermission(permissions.BasePermission):
"""
A user may read a competency's criteria tree if they can view the tag's taxonomy.

Authentication comes from ``IsAuthenticated`` on the view, so this class only does the
object-level check, which the view runs through ``check_object_permissions()``. That check
asks ``oel_tagging.view_taxonomy`` because openedx-platform overrides that rule with its
org-aware one and sets ``oel_tagging.view_tag`` to always allow.
"""

def has_object_permission(self, request, view, obj) -> bool:
"""Return True if the requesting user may view the taxonomy that ``obj`` (a Tag) belongs to."""
# has_perm(..., None) would fall back to a global rule, so a tag without a taxonomy is refused.
return obj.taxonomy is not None and request.user.has_perm("oel_tagging.view_taxonomy", obj.taxonomy)
46 changes: 45 additions & 1 deletion src/openedx_learning/applets/cbe/rest_api/v1/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from opaque_keys.edx.keys import UsageKey
from rest_framework import serializers

from ...models import CompetencyCriterion, CompetencyRuleProfile, LogicOperator
from ...models import CompetencyCriteriaGroup, CompetencyCriterion, CompetencyRuleProfile, LogicOperator


class CompetencyRuleProfileSerializer(serializers.ModelSerializer):
Expand Down Expand Up @@ -119,3 +119,47 @@ def validate(self, attrs: dict) -> dict:
if unrecognized:
raise serializers.ValidationError({key: "This field is not recognized." for key in unrecognized})
return attrs


class CompetencyCriteriaGroupSerializer(serializers.ModelSerializer):
"""
Read-only representation of a CompetencyCriteriaGroup, for the criteria-tree read endpoint.

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

course_key uses a CharField with a dotted source instead of SlugRelatedField, since
CourseRun.course_key is an opaque-keys field that the JSON renderer can't serialize directly;
CharField.to_representation() calls str() on it instead, and returns None cleanly when
`course` itself is null.
"""

course_key = serializers.CharField(source="course.course_key", read_only=True, allow_null=True)

class Meta:
model = CompetencyCriteriaGroup
fields = ["id", "parent_id", "tag_id", "course_key", "name", "ordering", "logic_operator"]


class CompetencyCriterionReadSerializer(serializers.ModelSerializer):
"""
Read-only representation of a CompetencyCriterion, for the criteria-tree read endpoint.

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

A separate serializer from CompetencyCriterionSerializer (#665's create-request serializer),
not an added field on it: that serializer's `object_id` is write_only, and giving it a dotted
source to double as this endpoint's output would silently break the create endpoint, since a
dotted source on a writable field changes where to_internal_value() places the value in
validated_data.
"""

object_id = serializers.CharField(source="object_tag.object_id", read_only=True)

class Meta:
model = CompetencyCriterion
fields = [
"id", "group_id", "object_tag_id", "object_id",
"rule_profile_id", "rule_type_override", "rule_payload_override",
]
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 @@ -22,4 +22,9 @@
views.CompetencyCriterionBulkUpdateView.as_view(),
name="criterion-bulk-update",
),
path(
"competencies/<int:tag_id>/criteria-groups/",
views.CompetencyCriteriaTreeView.as_view(),
name="criteria-tree",
),
]
36 changes: 35 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 @@ -24,14 +24,17 @@
CompetencyCriterionArchivedError,
associate_competency_criterion,
bulk_update_competency_criteria,
get_competency_criteria_tree,
get_competency_rule_profiles,
resolve_competency_tag,
)
from ...models import CompetencyCriteriaGroup, CompetencyCriterion, CompetencyRuleProfile
from ..paginators import CompetencyRuleProfilePagination
from .permissions import CompetencyRuleProfilePermissions
from .permissions import CompetencyReadPermission, CompetencyRuleProfilePermissions
from .serializers import (
CompetencyCriteriaGroupSerializer,
CompetencyCriterionBulkUpdateSerializer,
CompetencyCriterionReadSerializer,
CompetencyCriterionSerializer,
CompetencyRuleProfileSerializer,
)
Expand Down Expand Up @@ -164,3 +167,34 @@ def patch(self, request, group_id: int):
except CompetencyCriterionArchivedError as exc:
raise CompetencyCriteriaConflict(str(exc)) from exc
return Response(CompetencyCriterionSerializer(criteria, many=True).data, status=status.HTTP_200_OK)


class CompetencyCriteriaTreeView(generics.GenericAPIView):
"""
GET-only. Return a competency's CompetencyCriteriaGroup tree and CompetencyCriterion leaves.

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

Only groups whose whole ancestor chain is non-archived are returned, with their non-archived
criteria; see ``get_competency_criteria_tree``. Returned as two flat lists (groups, criteria)
for client-side reconstruction: each group carries its parent_id, and each criterion its
group_id. An empty tree (the tag exists but nothing has been authored under it yet) is a
valid 200 with two empty arrays; a 404 is reserved for tag resolution failure only.
"""

authentication_classes = (JwtAuthentication, SessionAuthenticationAllowInactiveUser)
permission_classes = [IsAuthenticated, CompetencyReadPermission]

def get(self, request, tag_id):
"""Return the competency's criteria tree; 404 if ``tag_id`` is not a competency tag."""
try:
tag = resolve_competency_tag(tag_id)
except Tag.DoesNotExist as exc:
raise NotFound(str(exc)) from exc
self.check_object_permissions(request, tag)
tree = get_competency_criteria_tree(tag)
return Response({
"groups": CompetencyCriteriaGroupSerializer(tree.groups, many=True).data,
"criteria": CompetencyCriterionReadSerializer(tree.criteria, many=True).data,
})
5 changes: 3 additions & 2 deletions src/openedx_learning/applets/cbe/rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@ def can_view_competency_rule_profile(
Course authors, not only platform staff, need to read rule profiles, so the collection and
the system default are open to anyone signed in. A taxonomy-scoped profile is as visible as
its taxonomy, which is the gate the criteria tree endpoint uses too, through
``oel_tagging.view_tag``; a profile on a disabled taxonomy stays admin-only. Course- and
organization-scoped profiles are refused to everyone until #645 decides who may see them.
``oel_tagging.view_taxonomy`` on the tag's taxonomy; a profile on a disabled taxonomy stays
admin-only. Course- and organization-scoped profiles are refused to everyone until #645
decides who may see them.

The signed-in requirement is added where the permission is registered, below, so ``has_perm``
refuses an anonymous caller whatever view sits in front of it. This predicate alone does not
Expand Down
Loading
Loading