diff --git a/lms/djangoapps/branding/__init__.py b/lms/djangoapps/branding/__init__.py index 0847f631cb6..954e5da9b1b 100644 --- a/lms/djangoapps/branding/__init__.py +++ b/lms/djangoapps/branding/__init__.py @@ -14,7 +14,7 @@ from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers -def get_visible_courses(org=None, filter_=None, active_only=False): +def get_visible_courses(org=None, filter_=None, active_only=False, course_keys=None): """ Yield the CourseOverviews that should be visible in this branded instance. @@ -25,6 +25,8 @@ def get_visible_courses(org=None, filter_=None, active_only=False): filter_ (dict): Optional parameter that allows custom filtering by fields on the course. active_only (bool): Optional parameter that enables fetching active courses only. + course_keys (list[str]): Optional parameter that allows for selecting which + courses to fetch the `CourseOverviews` for """ # Import is placed here to avoid model import at project startup. from openedx.core.djangoapps.content.course_overviews.models import CourseOverview @@ -36,12 +38,16 @@ def get_visible_courses(org=None, filter_=None, active_only=False): if org: # Check the current site's orgs to make sure the org's courses should be displayed if not current_site_orgs or org in current_site_orgs: - courses = CourseOverview.get_all_courses(orgs=[org], filter_=filter_, active_only=active_only) + courses = CourseOverview.get_all_courses( + orgs=[org], filter_=filter_, active_only=active_only, course_keys=course_keys + ) elif current_site_orgs: # Only display courses that should be displayed on this site - courses = CourseOverview.get_all_courses(orgs=current_site_orgs, filter_=filter_, active_only=active_only) + courses = CourseOverview.get_all_courses( + orgs=current_site_orgs, filter_=filter_, active_only=active_only, course_keys=course_keys + ) else: - courses = CourseOverview.get_all_courses(filter_=filter_, active_only=active_only) + courses = CourseOverview.get_all_courses(filter_=filter_, active_only=active_only, course_keys=course_keys) courses = courses.order_by('id') diff --git a/lms/djangoapps/course_api/api.py b/lms/djangoapps/course_api/api.py index 721c213992a..8210396441c 100644 --- a/lms/djangoapps/course_api/api.py +++ b/lms/djangoapps/course_api/api.py @@ -82,7 +82,7 @@ def course_detail(request, username, course_key): return overview -def _filter_by_search(course_queryset, search_term): +def _filter_by_search(course_queryset, search_term, mobile_search=False): """ Filters a course queryset by the specified search term. """ @@ -101,6 +101,13 @@ def _filter_by_search(course_queryset, search_term): search_courses_ids = {course['data']['id'] for course in search_courses['results']} + if mobile_search is True: + course_limit = getattr(settings, 'MOBILE_SEARCH_COURSE_LIMIT', 100) + courses = [course for course in course_queryset[:course_limit] if str(course.id) in search_courses_ids] + return LazySequence( + iter(courses), + est_len=len(courses) + ) return LazySequence( ( course for course in course_queryset @@ -116,7 +123,9 @@ def list_courses(request, filter_=None, search_term=None, permissions=None, - active_only=False): + active_only=False, + course_keys=None, + mobile_search=False): """ Yield all available courses. @@ -146,13 +155,21 @@ def list_courses(request, If specified, it filters visible `CourseOverview` objects by checking if each permission specified is granted for the username. active_only (bool): Optional parameter that enables fetching active courses only. + course_keys (list[str]): + If specified, it filters visible `CourseOverview` objects by + the course keys (ids) provided + mobile_search (bool): + Optional parameter that limits the number of returned courses + to MOBILE_SEARCH_COURSE_LIMIT. Return value: Yield `CourseOverview` objects representing the collection of courses. """ user = get_effective_user(request.user, username) - course_qs = get_courses(user, org=org, filter_=filter_, permissions=permissions, active_only=active_only) - course_qs = _filter_by_search(course_qs, search_term) + course_qs = get_courses( + user, org=org, filter_=filter_, permissions=permissions, active_only=active_only, course_keys=course_keys + ) + course_qs = _filter_by_search(course_qs, search_term, mobile_search) return course_qs diff --git a/lms/djangoapps/course_api/forms.py b/lms/djangoapps/course_api/forms.py index 055692da712..79dba03f45e 100644 --- a/lms/djangoapps/course_api/forms.py +++ b/lms/djangoapps/course_api/forms.py @@ -64,6 +64,8 @@ class CourseListGetForm(UsernameValidatorMixin, Form): mobile = ExtendedNullBooleanField(required=False) active_only = ExtendedNullBooleanField(required=False) permissions = MultiValueField(required=False) + course_keys = MultiValueField(required=False) + mobile_search = ExtendedNullBooleanField(required=False) def clean(self): """ @@ -80,6 +82,20 @@ def clean(self): return cleaned_data + def clean_course_keys(self): + """ + Ensure valid course_keys were provided. + """ + course_keys = self.cleaned_data['course_keys'] + if course_keys: + for course_key in course_keys: + try: + CourseKey.from_string(course_key) + except InvalidKeyError: + raise ValidationError(f"'{str(course_key)}' is not a valid course key.") # lint-amnesty, pylint: disable=raise-missing-from + + return course_keys + class CourseIdListGetForm(UsernameValidatorMixin, Form): """ diff --git a/lms/djangoapps/course_api/serializers.py b/lms/djangoapps/course_api/serializers.py index dbf4736d4e8..5fc71137678 100644 --- a/lms/djangoapps/course_api/serializers.py +++ b/lms/djangoapps/course_api/serializers.py @@ -5,6 +5,8 @@ import urllib +from common.djangoapps.student.models import CourseEnrollment +from django.contrib.auth import get_user_model from django.urls import reverse from edx_django_utils import monitoring as monitoring_utils from rest_framework import serializers @@ -164,10 +166,23 @@ def to_representation(self, instance): """ Get the `certificate_available_date` in response if the `certificates.auto_certificate_generation` waffle switch is enabled + + Get the 'is_enrolled' in response if 'username' is in query params, + user is staff, superuser, or user is authenticated and + the has the same 'username' as the 'username' in the query params. """ response = super().to_representation(instance) if can_show_certificate_available_date_field(instance): response['certificate_available_date'] = instance.certificate_available_date + + requested_username = self.context['request'].query_params.get('username', None) + if requested_username: + user = self.context['request'].user + if ((user.is_authenticated and user.username == requested_username) + or user.is_staff or user.is_superuser): + User = get_user_model() + requested_user = User.objects.get(username=requested_username) + response['is_enrolled'] = CourseEnrollment.is_enrolled(requested_user, instance.id) return response diff --git a/lms/djangoapps/course_api/tests/test_api.py b/lms/djangoapps/course_api/tests/test_api.py index 8d91b1ee97e..8e4d1253f8f 100644 --- a/lms/djangoapps/course_api/tests/test_api.py +++ b/lms/djangoapps/course_api/tests/test_api.py @@ -107,7 +107,8 @@ def _make_api_call(self, specified_user, org=None, filter_=None, - permissions=None): + permissions=None, + course_keys=None): """ Call the list_courses api endpoint to get information about `specified_user` on behalf of `requesting_user`. @@ -121,6 +122,7 @@ def _make_api_call(self, org=org, filter_=filter_, permissions=permissions, + course_keys=course_keys, ) def verify_courses(self, courses): @@ -244,6 +246,39 @@ def test_permissions(self): self.assertEqual({c.id for c in filtered_courses}, {self.course.id}) + def test_filter_by_keys(self): + """ + Verify that courses are filtered by the provided course keys. + """ + + # Create alternative courses to be included in the `course_keys` filter. + alternative_course_1 = self.create_course(course='alternative-course-1') + alternative_course_2 = self.create_course(course='alternative-course-2') + + # No filtering. + unfiltered_expected_courses = [ + self.course, + alternative_course_1, + alternative_course_2, + ] + unfiltered_courses = self._make_api_call(self.honor_user, self.honor_user) + assert {course.id for course in unfiltered_courses} == {course.id for course in unfiltered_expected_courses} + + # With filtering. + filtered_expected_courses = [ + alternative_course_1, + alternative_course_2, + ] + filtered_courses = self._make_api_call( + self.honor_user, + self.honor_user, + course_keys={ + alternative_course_1.id, + alternative_course_2.id + } + ) + assert {course.id for course in filtered_courses} == {course.id for course in filtered_expected_courses} + class TestGetCourseListExtras(CourseListTestMixin, ModuleStoreTestCase): """ diff --git a/lms/djangoapps/course_api/tests/test_forms.py b/lms/djangoapps/course_api/tests/test_forms.py index 9d14e5a9989..43a4aa104bd 100644 --- a/lms/djangoapps/course_api/tests/test_forms.py +++ b/lms/djangoapps/course_api/tests/test_forms.py @@ -71,6 +71,8 @@ def set_up_data(self, user): 'filter_': None, 'permissions': set(), 'active_only': None, + 'course_keys': set(), + 'mobile_search': None, } def test_basic(self): @@ -100,6 +102,14 @@ def test_filter(self, param_field_name, param_field_value): self.assert_valid(self.cleaned_data) + def test_invalid_course_keys(self): + """ + Verify form checks for validity of course keys provided + """ + + self.form_data['course_keys'] = 'course-v1:edX+DemoX+Demo_Course,invalid_course_key' + self.assert_error('course_keys', "'invalid_course_key' is not a valid course key.") + class TestCourseIdListGetForm(FormTestMixin, UsernameTestMixin, SharedModuleStoreTestCase): # lint-amnesty, pylint: disable=missing-class-docstring FORM_CLASS = CourseIdListGetForm diff --git a/lms/djangoapps/course_api/tests/test_serializers.py b/lms/djangoapps/course_api/tests/test_serializers.py index 4ee5e10265e..21be6b793c1 100644 --- a/lms/djangoapps/course_api/tests/test_serializers.py +++ b/lms/djangoapps/course_api/tests/test_serializers.py @@ -193,6 +193,49 @@ def test_basic(self): ) self.assertDictEqual(result, self.expected_data) + @mock.patch('lms.djangoapps.course_api.serializers.CourseEnrollment.is_enrolled', return_value=True) + def test_is_enrolled_field_true(self, mock_is_enrolled): + course = self.create_course() + result = self._get_result_with_query_param(course) + assert result['is_enrolled'] is True + mock_is_enrolled.assert_called_once() + + @mock.patch('lms.djangoapps.course_api.serializers.CourseEnrollment.is_enrolled', return_value=False) + def test_is_enrolled_field_false(self, mock_is_enrolled): + course = self.create_course() + result = self._get_result_with_query_param(course) + assert result['is_enrolled'] is False + mock_is_enrolled.assert_called_once() + + def test_is_enrolled_field_anonymous_user(self): + course = self.create_course() + result = self._get_anonymous_result(course) + self.assertNotIn('is_enrolled', result) + + def _get_anonymous_request(self): + return Request(self.request_factory.get('/')) + + def _get_anonymous_result(self, course): + course_overview = CourseOverview.get_from_id(course.id) + return self.serializer_class(course_overview, context={'request': self._get_anonymous_request()}).data + + def _get_result_with_query_param(self, course): + """ + Return the CourseSerializer for the specified course with 'username' in query params. + """ + course_overview = CourseOverview.get_from_id(course.id) + return self.serializer_class(course_overview, context={'request': self._get_request_with_query_param()}).data + + def _get_request_with_query_param(self, user=None): + """ + Build a Request object for the specified user with 'username' in query params. + """ + if user is None: + user = self.honor_user + request = Request(self.request_factory.get('/', {'username': user.username})) + request.user = user + return request + class TestCourseKeySerializer(TestCase): # lint-amnesty, pylint: disable=missing-class-docstring diff --git a/lms/djangoapps/course_api/tests/test_views.py b/lms/djangoapps/course_api/tests/test_views.py index 57b64d264ca..c39f808478e 100644 --- a/lms/djangoapps/course_api/tests/test_views.py +++ b/lms/djangoapps/course_api/tests/test_views.py @@ -449,6 +449,46 @@ def test_too_many_courses(self): assert len(response.data['results']) == (30 if (page < 11) else 3) assert [c['id'] for c in response.data['results']] == ordered_course_ids[((page - 1) * 30):(page * 30)] + def test_count_item_pagination_with_search_term(self): + """ + Test count items in pagination for api courses list - class CourseListView + """ + # Create 15 new courses, courses have the word "new" in the title + [self.create_and_index_course(f"numb_{number}", f"new_{number}") for number in range(15)] # pylint: disable=expression-not-assigned + response = self.verify_response(params={"search_term": "new"}) + self.assertEqual(response.status_code, 200) + # We don't have 'count' 15 because 'mobile_search' param is None + # And LazySequence contains all courses + self.assertEqual(response.json()["pagination"]["count"], 18) + + def test_count_item_pagination_with_search_term_and_filter(self): + """ + Test count items in pagination for api courses list + with search_term and filter by organisation - + class CourseListView + """ + # Create 25 new courses with two different organisations + [self.create_and_index_course("Org_N", f"new_{number}") for number in range(10)] # pylint: disable=expression-not-assigned + [self.create_and_index_course("Org_X", f"new_{number}") for number in range(15)] # pylint: disable=expression-not-assigned + response = self.verify_response(params={"org": "Org_X", "search_term": "new"}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["pagination"]["count"], 15) + + def test_count_item_pagination_with_search_term_and_mobile_search(self): + """ + Test count items in pagination for api courses list + with search_term and 'mobile_search' is True + """ + # Create 25 new courses with two different words in titles + [self.create_and_index_course("Org_N", f"old_{number}") for number in range(10)] # pylint: disable=expression-not-assigned + [self.create_and_index_course("Org_N", f"new_{number}") for number in range(15)] # pylint: disable=expression-not-assigned + response = self.verify_response( + params={"search_term": "new", "mobile_search": True} + ) + self.assertEqual(response.status_code, 200) + # We have 'count' 15 because 'mobile_search' param is true + self.assertEqual(response.json()["pagination"]["count"], 15) + class CourseIdListViewTestCase(CourseApiTestViewMixin, ModuleStoreTestCase): """ diff --git a/lms/djangoapps/course_api/views.py b/lms/djangoapps/course_api/views.py index fbb8517cf00..b2019c793fe 100644 --- a/lms/djangoapps/course_api/views.py +++ b/lms/djangoapps/course_api/views.py @@ -286,6 +286,14 @@ class CourseListView(DeveloperErrorViewMixin, ListAPIView): date are returned. This is different from search_term because this filtering is done on CourseOverview and not ElasticSearch. + course_keys (optional): + If specified, it fetches the `CourseOverview` objects for the + the specified course keys + + mobile_search (bool): + Optional parameter that limits the number of returned courses + to MOBILE_SEARCH_COURSE_LIMIT. + **Returns** * 200 on success, with a list of course discovery objects as returned @@ -343,7 +351,9 @@ def get_queryset(self): filter_=form.cleaned_data['filter_'], search_term=form.cleaned_data['search_term'], permissions=form.cleaned_data['permissions'], - active_only=form.cleaned_data.get('active_only', False) + active_only=form.cleaned_data.get('active_only', False), + course_keys=form.cleaned_data['course_keys'], + mobile_search=form.cleaned_data.get('mobile_search', False), ) diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py index 676a42bd70e..6ded860329f 100644 --- a/lms/djangoapps/courseware/courses.py +++ b/lms/djangoapps/courseware/courses.py @@ -753,7 +753,7 @@ def get_course_syllabus_section(course, section_key): @function_trace('get_courses') -def get_courses(user, org=None, filter_=None, permissions=None, active_only=False): +def get_courses(user, org=None, filter_=None, permissions=None, active_only=False, course_keys=None): """ Return a LazySequence of courses available, optionally filtered by org code (case-insensitive) or a set of permissions to be satisfied for the specified @@ -763,7 +763,8 @@ def get_courses(user, org=None, filter_=None, permissions=None, active_only=Fals courses = branding.get_visible_courses( org=org, filter_=filter_, - active_only=active_only + active_only=active_only, + course_keys=course_keys ).prefetch_related( 'modes', ).select_related( diff --git a/lms/djangoapps/discussion/django_comment_client/base/tests.py b/lms/djangoapps/discussion/django_comment_client/base/tests.py index bf346405ebf..ad6d021cd18 100644 --- a/lms/djangoapps/discussion/django_comment_client/base/tests.py +++ b/lms/djangoapps/discussion/django_comment_client/base/tests.py @@ -1860,6 +1860,34 @@ def test_thread_voted_event(self, view_name, obj_id_name, obj_type, mock_request assert event['undo_vote'] == undo assert event['vote_value'] == 'up' + @ddt.data('follow_thread', 'unfollow_thread',) + @patch('eventtracking.tracker.emit') + @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) + def test_thread_followed_event(self, view_name, mock_request, mock_emit): + self._set_mock_request_data(mock_request, { + 'closed': False, + 'commentable_id': 'test_commentable_id', + 'username': 'test_user', + }) + request = RequestFactory().post('dummy_url', {}) + request.user = self.student + request.view_name = view_name + view_function = getattr(views, view_name) + kwargs = dict(course_id=str(self.course.id)) + kwargs['thread_id'] = 'thread_id' + view_function(request, **kwargs) + + assert mock_emit.called + event_name, event_data = mock_emit.call_args[0] + action_name = 'followed' if view_name == 'follow_thread' else 'unfollowed' + expected_action_value = True if view_name == 'follow_thread' else False + assert event_name == f'edx.forum.thread.{action_name}' + assert event_data['commentable_id'] == 'test_commentable_id' + assert event_data['id'] == 'thread_id' + assert event_data['followed'] == expected_action_value + assert event_data['user_forums_roles'] == ['Student'] + assert event_data['user_course_roles'] == ['Wizard'] + class UsersEndpointTestCase(ForumsEnableMixin, SharedModuleStoreTestCase, MockRequestSetupMixin): diff --git a/lms/djangoapps/discussion/django_comment_client/base/views.py b/lms/djangoapps/discussion/django_comment_client/base/views.py index 54f8e493b52..4677e318f38 100644 --- a/lms/djangoapps/discussion/django_comment_client/base/views.py +++ b/lms/djangoapps/discussion/django_comment_client/base/views.py @@ -134,6 +134,20 @@ def track_thread_created_event(request, course, thread, followed, from_mfe_sideb track_created_event(request, event_name, course, thread, event_data) +def track_thread_followed_event(request, course, thread, followed): + """ + Send analytics event for a newly followed/unfollowed thread. + """ + action_name = 'followed' if followed else 'unfollowed' + event_name = _EVENT_NAME_TEMPLATE.format(obj_type='thread', action_name=action_name) + event_data = { + 'commentable_id': thread.commentable_id, + 'id': thread.id, + 'followed': followed, + } + track_forum_event(request, event_name, course, thread, event_data) + + def track_comment_created_event(request, course, comment, commentable_id, followed, from_mfe_sidebar=False): """ Send analytics event for a newly created response or comment. @@ -938,9 +952,12 @@ def un_pin_thread(request, course_id, thread_id): @permitted def follow_thread(request, course_id, thread_id): # lint-amnesty, pylint: disable=missing-function-docstring, unused-argument user = cc.User.from_django_user(request.user) + course_key = CourseKey.from_string(course_id) + course = get_course_by_id(course_key) thread = cc.Thread.find(thread_id) user.follow(thread) thread_followed.send(sender=None, user=request.user, post=thread) + track_thread_followed_event(request, course, thread, True) return JsonResponse({}) @@ -966,10 +983,13 @@ def unfollow_thread(request, course_id, thread_id): # lint-amnesty, pylint: dis given a course id and thread id, stop following this thread ajax only """ + course_key = CourseKey.from_string(course_id) + course = get_course_by_id(course_key) user = cc.User.from_django_user(request.user) thread = cc.Thread.find(thread_id) user.unfollow(thread) thread_unfollowed.send(sender=None, user=request.user, post=thread) + track_thread_followed_event(request, course, thread, False) return JsonResponse({}) diff --git a/lms/djangoapps/discussion/rest_api/api.py b/lms/djangoapps/discussion/rest_api/api.py index f480fb31fe2..fdf4e7c674b 100644 --- a/lms/djangoapps/discussion/rest_api/api.py +++ b/lms/djangoapps/discussion/rest_api/api.py @@ -89,7 +89,7 @@ track_voted_event, track_discussion_reported_event, track_discussion_unreported_event, - track_forum_search_event + track_forum_search_event, track_thread_followed_event ) from ..django_comment_client.utils import ( get_group_id_for_user, @@ -1323,7 +1323,7 @@ def _do_extra_actions(api_content, cc_content, request_fields, actions_form, con if field in request_fields and field in api_content and form_value != api_content[field]: api_content[field] = form_value if field == "following": - _handle_following_field(form_value, context["cc_requester"], cc_content) + _handle_following_field(form_value, context["cc_requester"], cc_content, request) elif field == "abuse_flagged": _handle_abuse_flagged_field(form_value, context["cc_requester"], cc_content, request) elif field == "voted": @@ -1336,12 +1336,15 @@ def _do_extra_actions(api_content, cc_content, request_fields, actions_form, con raise ValidationError({field: ["Invalid Key"]}) -def _handle_following_field(form_value, user, cc_content): +def _handle_following_field(form_value, user, cc_content, request): """follow/unfollow thread for the user""" + course_key = CourseKey.from_string(cc_content.course_id) + course = get_course_with_access(request.user, 'load', course_key) if form_value: user.follow(cc_content) else: user.unfollow(cc_content) + track_thread_followed_event(request, course, cc_content, form_value) def _handle_abuse_flagged_field(form_value, user, cc_content, request): diff --git a/lms/djangoapps/discussion/rest_api/serializers.py b/lms/djangoapps/discussion/rest_api/serializers.py index 6e17f201d0f..2d9056c87c7 100644 --- a/lms/djangoapps/discussion/rest_api/serializers.py +++ b/lms/djangoapps/discussion/rest_api/serializers.py @@ -6,7 +6,7 @@ from django.conf import settings from django.contrib.auth import get_user_model -from django.core.exceptions import ValidationError +from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.db.models import TextChoices from django.urls import reverse from django.utils.html import strip_tags @@ -41,6 +41,7 @@ from openedx.core.djangoapps.django_comment_common.comment_client.user import User as CommentClientUser 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.user_api.accounts.api import get_profile_images from openedx.core.lib.api.serializers import CourseKeyField User = get_user_model() @@ -83,6 +84,7 @@ def get_context(course, request, thread=None): "ta_user_ids": ta_user_ids, "cc_requester": cc_requester, "has_moderation_privilege": has_moderation_privilege, + "is_global_staff": is_global_staff, } @@ -155,6 +157,7 @@ class _ContentSerializer(serializers.Serializer): anonymous_to_peers = serializers.BooleanField(default=False) last_edit = serializers.SerializerMethodField(required=False) edit_reason_code = serializers.CharField(required=False, validators=[validate_edit_reason_code]) + edit_by_label = serializers.SerializerMethodField(required=False) non_updatable_fields = set() @@ -204,13 +207,25 @@ def _get_user_label(self, user_id): """ is_staff = user_id in self.context["course_staff_user_ids"] or user_id in self.context["moderator_user_ids"] is_ta = user_id in self.context["ta_user_ids"] + is_global_staff = self.context["is_global_staff"] return ( - "Staff" if is_staff else + "Staff" if (is_staff or is_global_staff) else "Community TA" if is_ta else None ) + def _get_user_label_from_username(self, username): + """ + Returns role label of user from username + Possible Role Labels: Staff, Community TA or None + """ + try: + user = User.objects.get(username=username) + return self._get_user_label(user.id) + except ObjectDoesNotExist: + return None + def get_author_label(self, obj): """ Returns the role label for the content author. @@ -282,6 +297,17 @@ def get_last_edit(self, obj): last_edit["reason"] = EDIT_REASON_CODES.get(reason_code) return last_edit + def get_edit_by_label(self, obj): + """ + Returns the role label for the last edit user. + """ + is_user_author = str(obj['user_id']) == str(self.context['request'].user.id) + is_user_privileged = _validate_privileged_access(self.context) + edit_history = obj.get("edit_history") + if (is_user_author or is_user_privileged) and edit_history: + last_edit = edit_history[-1] + return self._get_user_label_from_username(last_edit.get('editor_username')) + class ThreadSerializer(_ContentSerializer): """ @@ -316,6 +342,7 @@ class ThreadSerializer(_ContentSerializer): close_reason_code = serializers.CharField(required=False, validators=[validate_close_reason_code]) close_reason = serializers.SerializerMethodField() closed_by = serializers.SerializerMethodField() + closed_by_label = serializers.SerializerMethodField(required=False) non_updatable_fields = NON_UPDATABLE_THREAD_FIELDS @@ -425,6 +452,14 @@ def get_closed_by(self, obj): if _validate_privileged_access(self.context) or is_user_author: return obj.get("closed_by") + def get_closed_by_label(self, obj): + """ + Returns the role label for the user who closed the post. + """ + is_user_author = str(obj['user_id']) == str(self.context['request'].user.id) + if is_user_author or _validate_privileged_access(self.context): + return self._get_user_label_from_username(obj.get("closed_by")) + def create(self, validated_data): thread = Thread(user_id=self.context["cc_requester"]["id"], **validated_data) thread.save() @@ -469,6 +504,7 @@ class CommentSerializer(_ContentSerializer): child_count = serializers.IntegerField(read_only=True) children = serializers.SerializerMethodField(required=False) abuse_flagged_any_user = serializers.SerializerMethodField(required=False) + profile_image = serializers.SerializerMethodField(read_only=True) non_updatable_fields = NON_UPDATABLE_COMMENT_FIELDS @@ -551,6 +587,10 @@ def get_abuse_flagged_any_user(self, obj): if _validate_privileged_access(self.context): return len(obj.get("abuse_flaggers", [])) > 0 + def get_profile_image(self, obj): + request = self.context["request"] + return get_profile_images(request.user.profile, request.user, request) + def validate(self, attrs): """ Ensure that parent_id identifies a comment that is actually in the diff --git a/lms/djangoapps/discussion/rest_api/tests/test_api.py b/lms/djangoapps/discussion/rest_api/tests/test_api.py index d9188463fef..fd3024899a0 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_api.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_api.py @@ -1472,6 +1472,14 @@ def test_discussion_content(self): "anonymous": False, "anonymous_to_peers": False, "last_edit": None, + "edit_by_label": None, + "profile_image": { + "has_image": False, + "image_url_full": "http://testserver/static/default_500.png", + "image_url_large": "http://testserver/static/default_120.png", + "image_url_medium": "http://testserver/static/default_50.png", + "image_url_small": "http://testserver/static/default_30.png", + }, }, { "id": "test_comment_2", @@ -1498,6 +1506,14 @@ def test_discussion_content(self): "anonymous": True, "anonymous_to_peers": False, "last_edit": None, + "edit_by_label": None, + "profile_image": { + "has_image": False, + "image_url_full": "http://testserver/static/default_500.png", + "image_url_large": "http://testserver/static/default_120.png", + "image_url_medium": "http://testserver/static/default_50.png", + "image_url_small": "http://testserver/static/default_30.png", + }, }, ] actual_comments = self.get_comment_list( @@ -2232,6 +2248,14 @@ def test_success(self, parent_id, mock_emit): "anonymous": False, "anonymous_to_peers": False, "last_edit": None, + "edit_by_label": None, + "profile_image": { + "has_image": False, + "image_url_full": "http://testserver/static/default_500.png", + "image_url_large": "http://testserver/static/default_120.png", + "image_url_medium": "http://testserver/static/default_50.png", + "image_url_small": "http://testserver/static/default_30.png", + }, } assert actual == expected expected_url = ( @@ -2328,6 +2352,14 @@ def test_success_in_black_out_with_user_access(self, parent_id, mock_emit): "anonymous": False, "anonymous_to_peers": False, "last_edit": None, + "edit_by_label": None, + "profile_image": { + "has_image": False, + "image_url_full": "http://testserver/static/default_500.png", + "image_url_large": "http://testserver/static/default_120.png", + "image_url_medium": "http://testserver/static/default_50.png", + "image_url_small": "http://testserver/static/default_30.png", + }, } assert actual == expected expected_url = ( @@ -2684,7 +2716,8 @@ def test_author_only_fields(self, role_name): @ddt.data(*itertools.product([True, False], [True, False])) @ddt.unpack - def test_following(self, old_following, new_following): + @mock.patch("eventtracking.tracker.emit") + def test_following(self, old_following, new_following, mock_emit): """ Test attempts to edit the "following" field. @@ -2714,6 +2747,13 @@ def test_following(self, old_following, new_following): ) request_data.pop("request_id", None) assert request_data == {'source_type': ['thread'], 'source_id': ['test_thread']} + event_name, event_data = mock_emit.call_args[0] + expected_event_action = 'followed' if new_following else 'unfollowed' + assert event_name == f'edx.forum.thread.{expected_event_action}' + assert event_data['commentable_id'] == 'original_topic' + assert event_data['id'] == 'test_thread' + assert event_data['followed'] == new_following + assert event_data['user_forums_roles'] == ['Student'] @ddt.data(*itertools.product([True, False], [True, False])) @ddt.unpack @@ -3154,6 +3194,14 @@ def test_basic(self, parent_id): "child_count": 0, "can_delete": True, "last_edit": None, + "edit_by_label": None, + "profile_image": { + "has_image": False, + "image_url_full": "http://testserver/static/default_500.png", + "image_url_large": "http://testserver/static/default_120.png", + "image_url_medium": "http://testserver/static/default_50.png", + "image_url_small": "http://testserver/static/default_30.png", + }, } assert actual == expected assert parsed_body(httpretty.last_request()) == { diff --git a/lms/djangoapps/discussion/rest_api/tests/test_serializers.py b/lms/djangoapps/discussion/rest_api/tests/test_serializers.py index bd9cb0f1eac..240f78aa367 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_serializers.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_serializers.py @@ -188,6 +188,8 @@ def test_basic(self): "pinned": True, "editable_fields": ["abuse_flagged", "copy_link", "following", "read", "voted"], "abuse_flagged_count": None, + "edit_by_label": None, + "closed_by_label": None, }) assert self.serialize(thread) == expected @@ -242,20 +244,117 @@ def test_response_count_missing(self): serialized = self.serialize(thread_data) assert 'response_count' not in serialized - def test_get_preview_body(self): + @ddt.data( + (FORUM_ROLE_MODERATOR, True), + (FORUM_ROLE_STUDENT, False), + ("author", True), + ) + @ddt.unpack + def test_closed_by_label_field(self, role, visible): + """ + Tests if closed by field is visible to author and priviledged users """ - Test for the 'get_preview_body' method. + moderator = UserFactory() + request_role = FORUM_ROLE_STUDENT if role == "author" else role + author = self.user if role == "author" else self.author + self.create_role(FORUM_ROLE_MODERATOR, [moderator]) + self.create_role(request_role, [self.user]) + + thread = make_minimal_cs_thread({ + "id": "test_thread", + "course_id": str(self.course.id), + "commentable_id": "test_topic", + "user_id": str(author.id), + "username": author.username, + "title": "Test Title", + "body": "Test body", + "pinned": True, + "votes": {"up_count": 4}, + "comments_count": 5, + "unread_comments_count": 3, + "closed_by": moderator + }) + closed_by_label = "Staff" if visible else None + closed_by = moderator if visible else None + can_delete = role != FORUM_ROLE_STUDENT + editable_fields = ["abuse_flagged", "copy_link", "following", "read", "voted"] + if role == "author": + editable_fields.extend(['anonymous', 'raw_body', 'title', 'topic_id', 'type']) + elif role == FORUM_ROLE_MODERATOR: + editable_fields.extend(['close_reason_code', 'closed', 'edit_reason_code', 'pinned', + 'raw_body', 'title', 'topic_id', 'type']) + expected = self.expected_thread_data({ + "author": author.username, + "can_delete": can_delete, + "vote_count": 4, + "comment_count": 6, + "unread_comment_count": 3, + "pinned": True, + "editable_fields": sorted(editable_fields), + "abuse_flagged_count": None, + "edit_by_label": None, + "closed_by_label": closed_by_label, + "closed_by": closed_by, + }) + assert self.serialize(thread) == expected - This test verifies that the 'get_preview_body' method returns a cleaned - version of the thread's body that is suitable for display as a preview. - The test specifically focuses on handling the presence of multiple - spaces within the body. + @ddt.data( + (FORUM_ROLE_MODERATOR, True), + (FORUM_ROLE_STUDENT, False), + ("author", True), + ) + @ddt.unpack + def test_edit_by_label_field(self, role, visible): """ - thread_data = self.make_cs_content( - {"body": "
This is a test thread body with some text.
"} - ) - serialized = self.serialize(thread_data) - assert serialized['preview_body'] == "This is a test thread body with some text." + Tests if closed by field is visible to author and priviledged users + """ + moderator = UserFactory() + request_role = FORUM_ROLE_STUDENT if role == "author" else role + author = self.user if role == "author" else self.author + self.create_role(FORUM_ROLE_MODERATOR, [moderator]) + self.create_role(request_role, [self.user]) + + thread = make_minimal_cs_thread({ + "id": "test_thread", + "course_id": str(self.course.id), + "commentable_id": "test_topic", + "user_id": str(author.id), + "username": author.username, + "title": "Test Title", + "body": "Test body", + "pinned": True, + "votes": {"up_count": 4}, + "edit_history": [{"editor_username": moderator}], + "comments_count": 5, + "unread_comments_count": 3, + "closed_by": None + }) + edit_by_label = "Staff" if visible else None + can_delete = role != FORUM_ROLE_STUDENT + last_edit = None if role == FORUM_ROLE_STUDENT else {"editor_username": moderator} + editable_fields = ["abuse_flagged", "copy_link", "following", "read", "voted"] + + if role == "author": + editable_fields.extend(['anonymous', 'raw_body', 'title', 'topic_id', 'type']) + elif role == FORUM_ROLE_MODERATOR: + editable_fields.extend(['close_reason_code', 'closed', 'edit_reason_code', 'pinned', + 'raw_body', 'title', 'topic_id', 'type']) + + expected = self.expected_thread_data({ + "author": author.username, + "can_delete": can_delete, + "vote_count": 4, + "comment_count": 6, + "unread_comment_count": 3, + "pinned": True, + "editable_fields": sorted(editable_fields), + "abuse_flagged_count": None, + "last_edit": last_edit, + "edit_by_label": edit_by_label, + "closed_by_label": None, + "closed_by": None, + }) + assert self.serialize(thread) == expected @ddt.ddt @@ -333,6 +432,14 @@ def test_basic(self): "child_count": 0, "can_delete": False, "last_edit": None, + "edit_by_label": None, + "profile_image": { + "has_image": False, + "image_url_full": "http://testserver/static/default_500.png", + "image_url_large": "http://testserver/static/default_120.png", + "image_url_medium": "http://testserver/static/default_50.png", + "image_url_small": "http://testserver/static/default_30.png", + }, } assert self.serialize(comment) == expected diff --git a/lms/djangoapps/discussion/rest_api/tests/test_views.py b/lms/djangoapps/discussion/rest_api/tests/test_views.py index 559b4ff8f6c..dccabe1facd 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_views.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_views.py @@ -1709,6 +1709,8 @@ def test_basic(self): "votes": {"up_count": 4}, "comments_count": 5, "unread_comments_count": 3, + "closed_by_label": None, + "edit_by_label": None, })], "page": 1, "num_pages": 1, @@ -1968,6 +1970,14 @@ def expected_response_comment(self, overrides=None): "anonymous": False, "anonymous_to_peers": False, "last_edit": None, + "edit_by_label": None, + "profile_image": { + "has_image": False, + "image_url_full": "http://testserver/static/default_500.png", + "image_url_large": "http://testserver/static/default_120.png", + "image_url_medium": "http://testserver/static/default_50.png", + "image_url_small": "http://testserver/static/default_30.png", + }, } response_data.update(overrides or {}) return response_data @@ -2392,6 +2402,14 @@ def test_basic(self): "anonymous": False, "anonymous_to_peers": False, "last_edit": None, + "edit_by_label": None, + "profile_image": { + "has_image": False, + "image_url_full": "http://testserver/static/default_500.png", + "image_url_large": "http://testserver/static/default_120.png", + "image_url_medium": "http://testserver/static/default_50.png", + "image_url_small": "http://testserver/static/default_30.png", + }, } response = self.client.post( self.url, @@ -2483,6 +2501,14 @@ def expected_response_data(self, overrides=None): "anonymous": False, "anonymous_to_peers": False, "last_edit": None, + "edit_by_label": None, + "profile_image": { + "has_image": False, + "image_url_full": "http://testserver/static/default_500.png", + "image_url_large": "http://testserver/static/default_120.png", + "image_url_medium": "http://testserver/static/default_50.png", + "image_url_small": "http://testserver/static/default_30.png", + }, } response_data.update(overrides or {}) return response_data @@ -2672,6 +2698,14 @@ def test_basic(self): "anonymous": False, "anonymous_to_peers": False, "last_edit": None, + "edit_by_label": None, + "profile_image": { + "has_image": False, + "image_url_full": "http://testserver/static/default_500.png", + "image_url_large": "http://testserver/static/default_120.png", + "image_url_medium": "http://testserver/static/default_50.png", + "image_url_small": "http://testserver/static/default_30.png", + }, } response = self.client.get(self.url) diff --git a/lms/djangoapps/discussion/rest_api/tests/utils.py b/lms/djangoapps/discussion/rest_api/tests/utils.py index e4b52853244..fd456674c3a 100644 --- a/lms/djangoapps/discussion/rest_api/tests/utils.py +++ b/lms/djangoapps/discussion/rest_api/tests/utils.py @@ -499,7 +499,9 @@ def expected_thread_data(self, overrides=None): "type": "discussion", "response_count": 0, "last_edit": None, + "edit_by_label": None, "closed_by": None, + "closed_by_label": None, "close_reason": None, "close_reason_code": None, } diff --git a/lms/djangoapps/mobile_api/course_info/serializers.py b/lms/djangoapps/mobile_api/course_info/serializers.py new file mode 100644 index 00000000000..d7a9471088a --- /dev/null +++ b/lms/djangoapps/mobile_api/course_info/serializers.py @@ -0,0 +1,129 @@ +""" +Course Info serializers +""" +from rest_framework import serializers +from typing import Union + +from common.djangoapps.course_modes.models import CourseMode +from common.djangoapps.student.models import CourseEnrollment +from common.djangoapps.util.course import get_encoded_course_sharing_utm_params, get_link_for_about_page +from common.djangoapps.util.milestones_helpers import ( + get_pre_requisite_courses_not_completed, +) +from lms.djangoapps.courseware.access import has_access +from lms.djangoapps.courseware.access import administrative_accesses_to_course_for_user +from lms.djangoapps.courseware.access_utils import check_course_open_for_learner +from lms.djangoapps.mobile_api.users.serializers import ModeSerializer +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from openedx.features.course_duration_limits.access import get_user_course_expiration_date + + +class CourseInfoOverviewSerializer(serializers.ModelSerializer): + """ + Serializer for additional course fields that should be returned in BlocksInfoInCourseView. + """ + + name = serializers.CharField(source='display_name') + number = serializers.CharField(source='display_number_with_default') + org = serializers.CharField(source='display_org_with_default') + is_self_paced = serializers.BooleanField(source='self_paced') + media = serializers.SerializerMethodField() + course_sharing_utm_parameters = serializers.SerializerMethodField() + course_about = serializers.SerializerMethodField('get_course_about_url') + course_modes = serializers.SerializerMethodField() + + class Meta: + model = CourseOverview + fields = ( + 'name', + 'number', + 'org', + 'start', + 'start_display', + 'start_type', + 'end', + 'is_self_paced', + 'media', + 'course_sharing_utm_parameters', + 'course_about', + 'course_modes', + ) + + @staticmethod + def get_media(obj): + """ + Return course images in the correct format. + """ + return {'image': obj.image_urls} + + def get_course_sharing_utm_parameters(self, obj): + return get_encoded_course_sharing_utm_params() + + def get_course_about_url(self, course_overview): + return get_link_for_about_page(course_overview) + + def get_course_modes(self, course_overview): + """ + Retrieve course modes associated with the course. + """ + course_modes = CourseMode.modes_for_course( + course_overview.id, + only_selectable=False + ) + return [ + ModeSerializer(mode).data + for mode in course_modes + ] + + +class MobileCourseEnrollmentSerializer(serializers.ModelSerializer): + """ + Serializer for the CourseEnrollment object used in the BlocksInfoInCourseView. + """ + + class Meta: + fields = ('created', 'mode', 'is_active', 'upgrade_deadline') + model = CourseEnrollment + lookup_field = 'username' + + +class CourseAccessSerializer(serializers.Serializer): + """ + Get info whether a user should be able to view course material. + """ + + has_unmet_prerequisites = serializers.SerializerMethodField(method_name='get_has_unmet_prerequisites') + is_too_early = serializers.SerializerMethodField(method_name='get_is_too_early') + is_staff = serializers.SerializerMethodField(method_name='get_is_staff') + audit_access_expires = serializers.SerializerMethodField() + courseware_access = serializers.SerializerMethodField() + + def get_has_unmet_prerequisites(self, data: dict) -> bool: + """ + Check whether or not a course has unmet prerequisites. + """ + return any(get_pre_requisite_courses_not_completed(data.get('user'), [data.get('course_id')])) + + def get_is_too_early(self, data: dict) -> bool: + """ + Determine if the course is open to a learner (course has started or user has early beta access). + """ + return not check_course_open_for_learner(data.get('user'), data.get('course')) + + def get_is_staff(self, data: dict) -> bool: + """ + Determine whether a user has staff access to this course. + """ + return any(administrative_accesses_to_course_for_user(data.get('user'), data.get('course_id'))) + + def get_audit_access_expires(self, data: dict) -> Union[str, None]: + """ + Returns expiration date for a course audit expiration, if any or null + """ + return get_user_course_expiration_date(data.get('user'), data.get('course')) + + def get_courseware_access(self, data: dict) -> dict: + """ + Determine if the learner has access to the course, otherwise show error message. + """ + return has_access(data.get('user'), 'load_mobile', data.get('course')).to_json() diff --git a/lms/djangoapps/mobile_api/course_info/urls.py b/lms/djangoapps/mobile_api/course_info/urls.py index 5314b43bc5b..e369930e255 100644 --- a/lms/djangoapps/mobile_api/course_info/urls.py +++ b/lms/djangoapps/mobile_api/course_info/urls.py @@ -6,7 +6,7 @@ from django.conf import settings from django.urls import path, re_path -from .views import CourseHandoutsList, CourseUpdatesList, CourseGoalsRecordUserActivity +from .views import CourseHandoutsList, CourseUpdatesList, CourseGoalsRecordUserActivity, BlocksInfoInCourseView urlpatterns = [ re_path( @@ -20,4 +20,5 @@ name='course-updates-list' ), path('record_user_activity', CourseGoalsRecordUserActivity.as_view(), name='record_user_activity'), + path('blocks/', BlocksInfoInCourseView.as_view(), name="blocks_info_in_course"), ] diff --git a/lms/djangoapps/mobile_api/course_info/views.py b/lms/djangoapps/mobile_api/course_info/views.py index cb474a5fd75..fc48a7c4f93 100644 --- a/lms/djangoapps/mobile_api/course_info/views.py +++ b/lms/djangoapps/mobile_api/course_info/views.py @@ -3,17 +3,30 @@ """ import logging +from typing import Optional, Union +import django from django.contrib.auth import get_user_model from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey from rest_framework import generics, status from rest_framework.response import Response +from rest_framework.reverse import reverse from rest_framework.views import APIView +from common.djangoapps.student.models import CourseEnrollment, User as StudentUser from common.djangoapps.static_replace import make_static_urls_absolute +from lms.djangoapps.certificates.api import certificate_downloadable_status from lms.djangoapps.courseware.courses import get_course_info_section_block from lms.djangoapps.course_goals.models import UserActivity +from lms.djangoapps.course_api.blocks.views import BlocksInCourseView +from lms.djangoapps.mobile_api.course_info.serializers import ( + CourseInfoOverviewSerializer, + CourseAccessSerializer, + MobileCourseEnrollmentSerializer +) +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from openedx.core.lib.api.view_utils import view_auth_classes from openedx.core.lib.xblock_utils import get_course_update_items from openedx.features.course_experience import ENABLE_COURSE_GOALS from ..decorators import mobile_course_access, mobile_view @@ -21,6 +34,8 @@ User = get_user_model() log = logging.getLogger(__name__) +UserType = Union[django.contrib.auth.models.User, django.contrib.auth.models.AnonymousUser, StudentUser] + @mobile_view() class CourseUpdatesList(generics.ListAPIView): @@ -163,3 +178,202 @@ def post(self, request, *args, **kwargs): # Populate user activity for tracking progress towards a user's course goals UserActivity.record_user_activity(user, course_key) return Response(status=(200)) + + +@view_auth_classes(is_authenticated=False) +class BlocksInfoInCourseView(BlocksInCourseView): + """ + **Use Case** + + This API endpoint is specifically optimized for the course homepage on Mobile Apps. + The endpoint returns the blocks in the course according to the requesting user's access level. + Additionally, response encompasses info fields with information about the course, + including certificate URL, media dictionary with course image URLs, start and end dates for the course. + + **Example requests**: + + This api works with all versions {api_version}, you can use: v0.5, v1, v2 or v3 + + GET /api/mobile/{api_version}/course_info/blocks/?course_id=