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
21 changes: 12 additions & 9 deletions lms/lib/spectacular.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@
# addresses already have distinct ids and keep them.
COLLIDING_LEGACY_PATH = "/api/enrollment/v2/enrollments"

# Mounts published in the LMS schema. Anything else is left out.
SCHEMA_PATH_PATTERNS = (
re.compile(r"^/api/enrollment/v\d+/"),
re.compile(r"^/api/cohorts/v2/courses/[^/]+/(cohorts|cohort_settings)/"),
)


def _is_legacy_path(path):
"""Conforming routes always end in a slash, so a slashless v2 path is a legacy address."""
Expand All @@ -19,16 +25,13 @@ def _is_legacy_path(path):

def lms_api_filter(endpoints):
"""
Pre-processing hook: keep only enrollment v2 endpoints tagged for the SDK.
Pre-processing hook: keep only the endpoints under the mounts the LMS schema publishes.
"""
filtered = []
ENROLLMENT_PATH_PATTERN = re.compile(r"^/api/enrollment/v\d+/")

for path, path_regex, method, callback in endpoints:
if ENROLLMENT_PATH_PATTERN.match(path):
filtered.append((path, path_regex, method, callback))

return filtered
return [
(path, path_regex, method, callback)
for path, path_regex, method, callback in endpoints
if any(pattern.match(path) for pattern in SCHEMA_PATH_PATTERNS)
]


class LmsAutoSchema(AutoSchema):
Expand Down
16 changes: 12 additions & 4 deletions lms/lib/tests/test_spectacular.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,25 @@ def _schema(*routes):


class LmsApiFilterTest(SimpleTestCase):
"""The pre-processing hook keeps only enrollment endpoints."""
"""The pre-processing hook keeps only enrollment endpoints and the cohorts v2 resources."""

def test_keeps_enrollment_drops_the_rest(self):
def test_keeps_enrollment_and_the_cohorts_v2_resources_only(self):
kept = lms_api_filter([
_endpoint("/api/enrollment/v2/enrollments/"),
_endpoint("/api/enrollment/v1/enrollment"),
_endpoint("/api/enrollment/v2/enrollments/"),
_endpoint("/api/cohorts/v2/courses/{course_key_string}/cohorts/"),
_endpoint("/api/cohorts/v2/courses/{course_key_string}/cohorts/{cohort_id}/users/{username}/"),
_endpoint("/api/cohorts/v2/courses/{course_key_string}/cohort_settings/"),
_endpoint("/api/cohorts/v2/courses/{course_id}/group_configurations"),
_endpoint("/api/cohorts/v1/courses/{course_key_string}/cohorts/"),
_endpoint("/api/user/v1/accounts/"),
])
assert [path for path, *_ in kept] == [
"/api/enrollment/v2/enrollments/",
"/api/enrollment/v1/enrollment",
"/api/enrollment/v2/enrollments/",
"/api/cohorts/v2/courses/{course_key_string}/cohorts/",
"/api/cohorts/v2/courses/{course_key_string}/cohorts/{cohort_id}/users/{username}/",
"/api/cohorts/v2/courses/{course_key_string}/cohort_settings/",
]


Expand Down
3 changes: 3 additions & 0 deletions openedx/core/djangoapps/course_groups/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,6 @@
CONTENT_GROUP_CONFIGURATION_DESCRIPTION = _(
'Use this group configuration to control access to content.'
)

# Usernames as accepted by the platform: letters, digits and . @ + - _
USERNAME_LOOKUP_REGEX = r"[\w.@+-]+"
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""
Permissions for the v2 cohorts REST API.
"""
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from rest_framework import permissions

from common.djangoapps.student.roles import GlobalStaff
from lms.djangoapps.courseware.access import administrative_accesses_to_course_for_user
from lms.djangoapps.discussion.django_comment_client.utils import get_user_role_names
from openedx.core.djangoapps.django_comment_common.models import (
FORUM_ROLE_ADMINISTRATOR,
FORUM_ROLE_COMMUNITY_TA,
FORUM_ROLE_MODERATOR,
)

DISCUSSION_PRIVILEGED_ROLES = {
FORUM_ROLE_ADMINISTRATOR,
FORUM_ROLE_MODERATOR,
FORUM_ROLE_COMMUNITY_TA,
}


class CanManageCohorts(permissions.BasePermission):
"""
Grants access to the cohort endpoints of a course.

Course staff and instructors may read and write. Discussion moderators,
administrators and community TAs may read only. Global staff and
superusers may do either.

A course key that cannot be parsed is denied rather than raising, so a
malformed path cannot surface as a server error.
"""

def has_permission(self, request, view):
user = request.user
if not (user and user.is_authenticated):
return False
if GlobalStaff().has_user(user) or user.is_superuser:
return True

try:
course_key = CourseKey.from_string(view.kwargs.get("course_key_string", ""))
except InvalidKeyError:
return False

_, staff_access, instructor_access = administrative_accesses_to_course_for_user(user, course_key)
if staff_access or instructor_access:
return True

moderates_discussions = bool(get_user_role_names(user, course_key) & DISCUSSION_PRIVILEGED_ROLES)
return moderates_discussions and request.method in permissions.SAFE_METHODS
131 changes: 131 additions & 0 deletions openedx/core/djangoapps/course_groups/rest_api/cohort_serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"""
Serializers for the v2 cohorts REST API.
"""
from django.contrib.auth import get_user_model
from rest_framework import serializers

from openedx.core.djangoapps.course_groups import cohorts

User = get_user_model()

ASSIGNMENT_TYPE_CHOICES = ("manual", "random")


class CohortSerializer(serializers.Serializer):
"""
Representation of a single cohort within a course.

``CourseUserGroup`` carries the name and membership, while the assignment
type and content-group association live on related models, so the fields
are declared explicitly rather than derived from one model.
"""

id = serializers.IntegerField(read_only=True, help_text="Identifier of the cohort.")
name = serializers.CharField(max_length=255, help_text="Display name of the cohort.")
assignment_type = serializers.ChoiceField(
choices=ASSIGNMENT_TYPE_CHOICES,
help_text="How learners are placed into this cohort.",
)
user_count = serializers.IntegerField(
read_only=True,
help_text="Number of actively enrolled learners in this cohort.",
)
user_partition_id = serializers.IntegerField(
required=False,
allow_null=True,
help_text="Identifier of the content group configuration this cohort is associated with.",
)
group_id = serializers.IntegerField(
required=False,
allow_null=True,
help_text="Identifier of the content group within the configuration. Send null to unlink.",
)


class CohortUpdateSerializer(CohortSerializer):
"""
Update payload for a cohort, where every field is optional.

``group_id`` is nullable and meaningful when explicitly set to null, which
unlinks the content group association.
"""

name = serializers.CharField(max_length=255, required=False)
assignment_type = serializers.ChoiceField(choices=ASSIGNMENT_TYPE_CHOICES, required=False)

def validate(self, attrs):
if not attrs:
raise serializers.ValidationError(
"Supply at least one of name, assignment_type or group_id."
)
return attrs


class CohortMemberSerializer(serializers.ModelSerializer):
"""
Representation of a learner who belongs to a cohort.
"""

name = serializers.SerializerMethodField()

class Meta:
model = User
fields = ("username", "email", "name")

def get_name(self, user) -> str:
"""Return the learner's full name."""
return f"{user.first_name} {user.last_name}".strip()


class CohortMembershipRequestSerializer(serializers.Serializer):
"""
Request payload for adding learners to a cohort.
"""

users = serializers.ListField(
child=serializers.CharField(allow_blank=False),
allow_empty=False,
help_text="Usernames or email addresses to place in this cohort.",
)


class CohortMembershipResultSerializer(serializers.Serializer):
"""
Outcome of an add-learners request, partitioned by what happened to each entry.
"""

added = serializers.ListField(child=serializers.DictField())
changed = serializers.ListField(child=serializers.DictField())
present = serializers.ListField(child=serializers.CharField())
unknown = serializers.ListField(child=serializers.CharField())
preassigned = serializers.ListField(child=serializers.CharField())
invalid = serializers.ListField(child=serializers.CharField())


class CohortSettingsSerializer(serializers.Serializer):
"""
Course-level cohort configuration.
"""

id = serializers.IntegerField(read_only=True, allow_null=True)
is_cohorted = serializers.BooleanField(
help_text="Whether cohorts are enabled for this course.",
)


def represent_cohort(cohort, course_key):
"""
Build the serializable representation of a cohort.
"""
group_id, partition_id = cohorts.get_group_info_for_cohort(cohort)
return {
"id": cohort.id,
"name": cohort.name,
"assignment_type": cohorts.get_assignment_type(cohort),
"user_count": cohort.users.filter(
courseenrollment__course_id=course_key,
courseenrollment__is_active=1,
).count(),
"user_partition_id": partition_id,
"group_id": group_id,
}
Loading
Loading