diff --git a/lms/djangoapps/discussion/django_comment_client/utils.py b/lms/djangoapps/discussion/django_comment_client/utils.py index 523d0fc852..151277edd8 100644 --- a/lms/djangoapps/discussion/django_comment_client/utils.py +++ b/lms/djangoapps/discussion/django_comment_client/utils.py @@ -1,6 +1,8 @@ # pylint: skip-file import json import logging +from typing import Set + import regex from collections import defaultdict from datetime import datetime @@ -85,6 +87,26 @@ def get_role_ids(course_id): return {role.name: list(role.users.values_list('id', flat=True)) for role in roles} +def get_user_role_names(user: User, course_key: CourseKey) -> Set[str]: + """ + Get a set of discussion roles a user has for the specified course. + + Args: + user (User): a user + course_key (CourseKey): a course key + + Returns: + (Set[str]) a set of role names that the user has. + + """ + return set( + Role.objects.filter( + users=user, + course_id=course_key, + ).values_list('name', flat=True).distinct() + ) + + def has_discussion_privileges(user, course_id): """ Returns True if the user is privileged in teams discussions for diff --git a/lms/djangoapps/discussion/rest_api/api.py b/lms/djangoapps/discussion/rest_api/api.py index 862c0178d8..8f0c694566 100644 --- a/lms/djangoapps/discussion/rest_api/api.py +++ b/lms/djangoapps/discussion/rest_api/api.py @@ -25,7 +25,12 @@ from openedx.core.djangoapps.django_comment_common.comment_client.comment import from openedx.core.djangoapps.django_comment_common.comment_client.course import get_course_commentable_counts from openedx.core.djangoapps.django_comment_common.comment_client.thread import Thread from openedx.core.djangoapps.django_comment_common.comment_client.utils import CommentClientRequestError -from openedx.core.djangoapps.django_comment_common.models import CourseDiscussionSettings +from openedx.core.djangoapps.django_comment_common.models import ( + CourseDiscussionSettings, + FORUM_ROLE_ADMINISTRATOR, + FORUM_ROLE_COMMUNITY_TA, + FORUM_ROLE_MODERATOR, +) from openedx.core.djangoapps.django_comment_common.signals import ( comment_created, comment_deleted, @@ -39,6 +44,7 @@ from openedx.core.djangoapps.django_comment_common.signals import ( from openedx.core.djangoapps.user_api.accounts.api import get_account_settings from openedx.core.lib.exceptions import CourseNotFoundError, DiscussionNotFoundError, PageNotFoundError from xmodule.course_module import CourseBlock +from xmodule.tabs import CourseTabList from .exceptions import ( CommentNotFoundError, DiscussionBlackOutException, @@ -67,9 +73,9 @@ from ..django_comment_client.base.views import ( ) from ..django_comment_client.utils import ( get_group_id_for_user, + get_user_role_names, is_commentable_divided, ) -from xmodule.tabs import CourseTabList User = get_user_model() @@ -247,6 +253,7 @@ def get_course(request, course_key): return dt.isoformat().replace('+00:00', 'Z') course = _get_course(course_key, request.user) + user_roles = get_user_role_names(request.user, course_key) return { "id": str(course_key), "blackouts": [ @@ -263,6 +270,12 @@ def get_course(request, course_key): ), "allow_anonymous": course.allow_anonymous, "allow_anonymous_to_peers": course.allow_anonymous_to_peers, + "user_roles": user_roles, + "user_is_privileged": bool(user_roles & { + FORUM_ROLE_ADMINISTRATOR, + FORUM_ROLE_MODERATOR, + FORUM_ROLE_COMMUNITY_TA, + }) } diff --git a/lms/djangoapps/discussion/rest_api/serializers.py b/lms/djangoapps/discussion/rest_api/serializers.py index 662c622b50..23b506cec6 100644 --- a/lms/djangoapps/discussion/rest_api/serializers.py +++ b/lms/djangoapps/discussion/rest_api/serializers.py @@ -18,7 +18,6 @@ from lms.djangoapps.discussion.django_comment_client.utils import ( get_group_name, is_comment_too_deep, ) -from openedx.core.djangoapps.discussions.utils import get_group_names_by_id from lms.djangoapps.discussion.rest_api.permissions import ( NON_UPDATABLE_COMMENT_FIELDS, NON_UPDATABLE_THREAD_FIELDS, @@ -26,6 +25,7 @@ from lms.djangoapps.discussion.rest_api.permissions import ( get_editable_fields, ) from lms.djangoapps.discussion.rest_api.render import render_body +from openedx.core.djangoapps.discussions.utils import get_group_names_by_id from openedx.core.djangoapps.django_comment_common.comment_client.comment import Comment from openedx.core.djangoapps.django_comment_common.comment_client.thread import Thread from openedx.core.djangoapps.django_comment_common.comment_client.user import User as CommentClientUser @@ -37,6 +37,7 @@ from openedx.core.djangoapps.django_comment_common.models import ( FORUM_ROLE_MODERATOR, Role, ) +from openedx.core.lib.api.serializers import CourseKeyField User = get_user_model() @@ -631,3 +632,43 @@ class DiscussionRolesListSerializer(serializers.Serializer): Overriden update abstract method """ pass # lint-amnesty, pylint: disable=unnecessary-pass + + +class BlackoutDateSerializer(serializers.Serializer): + """ + Serializer for blackout dates. + """ + start = serializers.DateTimeField(help_text="The ISO 8601 timestamp for the start of the blackout period") + end = serializers.DateTimeField(help_text="The ISO 8601 timestamp for the end of the blackout period") + + +class CourseMetadataSerailizer(serializers.Serializer): + """ + Serializer for course metadata. + """ + id = CourseKeyField(help_text="The identifier of the course") + blackouts = serializers.ListField( + child=BlackoutDateSerializer(), + help_text="A list of objects representing blackout periods " + "(during which discussions are read-only except for privileged users)." + ) + thread_list_url = serializers.URLField( + help_text="The URL of the list of all threads in the course.", + ) + following_thread_list_url = serializers.URLField( + help_text="thread_list_url with parameter following=True", + ) + topics_url = serializers.URLField(help_text="The URL of the topic listing for the course.") + allow_anonymous = serializers.BooleanField( + help_text="A boolean which indicating whether anonymous posts are allowed or not.", + ) + allow_anonymous_to_peers = serializers.BooleanField( + help_text="A boolean which indicating whether posts anonymous to peers are allowed or not.", + ) + user_roles = serializers.ListField( + child=serializers.CharField(), + help_text="A list of all the roles the requesting user has for this course.", + ) + user_is_privileged = serializers.BooleanField( + help_text="A boolean indicating if the current user has a privileged role", + ) diff --git a/lms/djangoapps/discussion/rest_api/tests/test_api.py b/lms/djangoapps/discussion/rest_api/tests/test_api.py index bc2644fa8e..e7e8abd5c3 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_api.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_api.py @@ -139,6 +139,7 @@ def _set_course_discussion_blackout(course, user_id): @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +@ddt.ddt class GetCourseTest(ForumsEnableMixin, UrlResetMixin, SharedModuleStoreTestCase): """Test for get_course""" @@ -179,8 +180,24 @@ class GetCourseTest(ForumsEnableMixin, UrlResetMixin, SharedModuleStoreTestCase) 'topics_url': 'http://testserver/api/discussion/v1/course_topics/x/y/z', 'allow_anonymous': True, 'allow_anonymous_to_peers': False, + 'user_is_privileged': False, + 'user_roles': {'Student'}, } + @ddt.data( + FORUM_ROLE_ADMINISTRATOR, + FORUM_ROLE_MODERATOR, + FORUM_ROLE_COMMUNITY_TA, + ) + def test_privileged_roles(self, role): + """ + Test that the api returns the correct roles and privileges. + """ + _assign_role_to_user(user=self.user, course_id=self.course.id, role=role) + course_meta = get_course(self.request, self.course.id) + assert course_meta["user_is_privileged"] + assert course_meta["user_roles"] == {FORUM_ROLE_STUDENT} | {role} + @ddt.ddt @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) diff --git a/lms/djangoapps/discussion/rest_api/tests/test_views.py b/lms/djangoapps/discussion/rest_api/tests/test_views.py index 106fb581fe..a793ca92be 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_views.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_views.py @@ -315,6 +315,8 @@ class CourseViewTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): "topics_url": "http://testserver/api/discussion/v1/course_topics/x/y/z", "allow_anonymous": True, "allow_anonymous_to_peers": False, + 'user_is_privileged': False, + 'user_roles': ['Student'], } ) diff --git a/lms/djangoapps/discussion/rest_api/views.py b/lms/djangoapps/discussion/rest_api/views.py index aa4a260575..fdfff79c09 100644 --- a/lms/djangoapps/discussion/rest_api/views.py +++ b/lms/djangoapps/discussion/rest_api/views.py @@ -6,9 +6,9 @@ Discussion API views import logging import uuid -from django.core.exceptions import BadRequest +import edx_api_doc_tools as apidocs from django.contrib.auth import get_user_model -from django.core.exceptions import ValidationError +from django.core.exceptions import BadRequest, ValidationError from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser from opaque_keys.edx.keys import CourseKey @@ -21,8 +21,8 @@ from rest_framework.views import APIView from rest_framework.viewsets import ViewSet from common.djangoapps.util.file import store_uploaded_file -from lms.djangoapps.discussion.django_comment_client import settings as cc_settings from lms.djangoapps.course_goals.models import UserActivity +from lms.djangoapps.discussion.django_comment_client import settings as cc_settings from lms.djangoapps.instructor.access import update_forum_role from openedx.core.djangoapps.discussions.serializers import DiscussionSettingsSerializer from openedx.core.djangoapps.django_comment_common import comment_client @@ -54,11 +54,12 @@ from ..rest_api.forms import ( CourseDiscussionSettingsForm, ThreadListGetForm, ) +from ..rest_api.permissions import IsStaffOrCourseTeamOrEnrolled from ..rest_api.serializers import ( + CourseMetadataSerailizer, DiscussionRolesListSerializer, DiscussionRolesSerializer, ) -from ..rest_api.permissions import IsStaffOrCourseTeamOrEnrolled log = logging.getLogger(__name__) @@ -68,41 +69,28 @@ User = get_user_model() @view_auth_classes() class CourseView(DeveloperErrorViewMixin, APIView): """ - **Use Cases** - - Retrieve general discussion metadata for a course. - - **Example Requests**: - - GET /api/discussion/v1/courses/course-v1:ExampleX+Subject101+2015 - - **Response Values**: - - * id: The identifier of the course - - * blackouts: A list of objects representing blackout periods (during - which discussions are read-only except for privileged users). Each - item in the list includes: - - * start: The ISO 8601 timestamp for the start of the blackout period - - * end: The ISO 8601 timestamp for the end of the blackout period - - * thread_list_url: The URL of the list of all threads in the course. - - * following_thread_list_url: thread_list_url with parameter following=True - - * topics_url: The URL of the topic listing for the course. - - * allow_anonymous: A boolean which indicating whether anonymous posts - are allowed or not. - - * allow_anonymous_to_peers: A boolean which indicating whether posts - anonymous to peers are allowed or not. + General discussion metadata API. """ + @apidocs.schema( + parameters=[ + apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID") + ], + responses={ + 200: CourseMetadataSerailizer(read_only=True, required=False), + 401: "The requester is not authenticated.", + 403: "The requester cannot access the specified course.", + 404: "The requested course does not exist.", + } + ) def get(self, request, course_id): - """Implements the GET method as described in the class docstring.""" + """ + Retrieve general discussion metadata for a course. + + **Example Requests**: + + GET /api/discussion/v1/courses/course-v1:ExampleX+Subject101+2015 + """ course_key = CourseKey.from_string(course_id) # TODO: which class is right? # Record user activity for tracking progress towards a user's course goals (for mobile app) UserActivity.record_user_activity(request.user, course_key, request=request, only_if_mobile_app=True)