diff --git a/lms/djangoapps/course_goals/api.py b/lms/djangoapps/course_goals/api.py index 6c8fe3c9f8..608e9cf8eb 100644 --- a/lms/djangoapps/course_goals/api.py +++ b/lms/djangoapps/course_goals/api.py @@ -88,12 +88,20 @@ def get_course_goal_options(): return {goal_key: goal_text for goal_key, goal_text in models.GOAL_KEY_CHOICES} -def valid_course_goals_ordered(): +def get_course_goal_text(goal_key): + """ + Returns the translated string for the given goal key + """ + goal_options = get_course_goal_options() + return goal_options[goal_key] + + +def valid_course_goals_ordered(include_unsure=False): """ Returns a list of the valid options for goal keys ordered by the level of commitment. Each option is represented as a tuple, with (goal_key, goal_string). - This list does not return the unsure option since it does not have a relevant commitment level. + This list does not return the unsure option by default since it does not have a relevant commitment level. """ goal_options = get_course_goal_options() @@ -102,4 +110,7 @@ def valid_course_goals_ordered(): ordered_goal_options.append((models.GOAL_KEY_CHOICES.complete, goal_options[models.GOAL_KEY_CHOICES.complete])) ordered_goal_options.append((models.GOAL_KEY_CHOICES.explore, goal_options[models.GOAL_KEY_CHOICES.explore])) + if include_unsure: + ordered_goal_options.append((models.GOAL_KEY_CHOICES.unsure, goal_options[models.GOAL_KEY_CHOICES.unsure])) + return ordered_goal_options diff --git a/lms/djangoapps/course_goals/views.py b/lms/djangoapps/course_goals/views.py index 70c4c70bef..c98ff66aad 100644 --- a/lms/djangoapps/course_goals/views.py +++ b/lms/djangoapps/course_goals/views.py @@ -3,7 +3,6 @@ Course Goals Views - includes REST API """ -from django.conf import settings from django.contrib.auth import get_user_model from django.db.models.signals import post_save from django.dispatch import receiver @@ -58,6 +57,7 @@ class CourseGoalViewSet(viewsets.ModelViewSet): queryset = CourseGoal.objects.all() serializer_class = CourseGoalSerializer + # Another version of this endpoint exists in ../course_home_api/outline/v1/views.py def create(self, post_data): """ Create a new goal if one does not exist, otherwise update the existing goal. """ # Ensure goal_key is valid diff --git a/lms/djangoapps/course_home_api/outline/v1/serializers.py b/lms/djangoapps/course_home_api/outline/v1/serializers.py index 78c3c04ceb..a730a3211a 100644 --- a/lms/djangoapps/course_home_api/outline/v1/serializers.py +++ b/lms/djangoapps/course_home_api/outline/v1/serializers.py @@ -7,21 +7,6 @@ from lms.djangoapps.course_home_api.dates.v1.serializers import DateSummarySeria from rest_framework.reverse import reverse -class CourseToolSerializer(serializers.Serializer): - """ - Serializer for Course Tool Objects - """ - analytics_id = serializers.CharField() - title = serializers.CharField() - url = serializers.SerializerMethodField() - - def get_url(self, tool): - course_key = self.context.get('course_key') - url = tool.url(course_key) - request = self.context.get('request') - return request.build_absolute_uri(url) - - class CourseBlockSerializer(serializers.Serializer): """ Serializer for Course Block Objects @@ -45,6 +30,29 @@ class CourseBlockSerializer(serializers.Serializer): } +class CourseGoalSerializer(serializers.Serializer): + """ + Serializer for Course Goal data + """ + goal_options = serializers.ListField() + selected_goal = serializers.DictField() + + +class CourseToolSerializer(serializers.Serializer): + """ + Serializer for Course Tool Objects + """ + analytics_id = serializers.CharField() + title = serializers.CharField() + url = serializers.SerializerMethodField() + + def get_url(self, tool): + course_key = self.context.get('course_key') + url = tool.url(course_key) + request = self.context.get('request') + return request.build_absolute_uri(url) + + class DatesWidgetSerializer(serializers.Serializer): """ Serializer for Dates Widget data @@ -63,6 +71,9 @@ class EnrollAlertSerializer(serializers.Serializer): class ResumeCourseSerializer(serializers.Serializer): + """ + Serializer for resume course data + """ has_visited_course = serializers.BooleanField() url = serializers.URLField() @@ -73,6 +84,7 @@ class OutlineTabSerializer(serializers.Serializer): """ course_blocks = CourseBlockSerializer() course_expired_html = serializers.CharField() + course_goals = CourseGoalSerializer() course_tools = CourseToolSerializer(many=True) dates_widget = DatesWidgetSerializer() enroll_alert = EnrollAlertSerializer() diff --git a/lms/djangoapps/course_home_api/outline/v1/tests/test_views.py b/lms/djangoapps/course_home_api/outline/v1/tests/test_views.py index c41bcdb3d7..fff38d645a 100644 --- a/lms/djangoapps/course_home_api/outline/v1/tests/test_views.py +++ b/lms/djangoapps/course_home_api/outline/v1/tests/test_views.py @@ -11,7 +11,7 @@ from lms.djangoapps.course_home_api.tests.utils import BaseCourseHomeTests from lms.djangoapps.course_home_api.toggles import COURSE_HOME_MICROFRONTEND, COURSE_HOME_MICROFRONTEND_OUTLINE_TAB from openedx.core.djangoapps.user_api.preferences.api import set_user_preference from openedx.core.djangoapps.user_api.tests.factories import UserCourseTagFactory -from openedx.features.course_experience import COURSE_ENABLE_UNENROLLED_ACCESS_FLAG +from openedx.features.course_experience import COURSE_ENABLE_UNENROLLED_ACCESS_FLAG, ENABLE_COURSE_GOALS from student.models import CourseEnrollment from student.tests.factories import UserFactory from xmodule.course_module import COURSE_VISIBILITY_PUBLIC @@ -27,6 +27,7 @@ class OutlineTabTestViews(BaseCourseHomeTests): super().setUp() self.url = reverse('course-home-outline-tab', args=[self.course.id]) + @ENABLE_COURSE_GOALS.override(active=True) @COURSE_HOME_MICROFRONTEND.override(active=True) @COURSE_HOME_MICROFRONTEND_OUTLINE_TAB.override(active=True) @ddt.data(CourseMode.AUDIT, CourseMode.VERIFIED) @@ -35,6 +36,16 @@ class OutlineTabTestViews(BaseCourseHomeTests): response = self.client.get(self.url) self.assertEqual(response.status_code, 200) + course_goals = response.data.get('course_goals') + goal_options = course_goals['goal_options'] + if enrollment_mode == CourseMode.VERIFIED: + self.assertEqual(goal_options, []) + else: + self.assertGreater(len(goal_options), 0) + + selected_goal = course_goals['selected_goal'] + self.assertIsNone(selected_goal) + course_tools = response.data.get('course_tools') self.assertTrue(course_tools) self.assertEqual(course_tools[0]['analytics_id'], 'edx.bookmarks') @@ -56,6 +67,9 @@ class OutlineTabTestViews(BaseCourseHomeTests): response = self.client.get(self.url) self.assertEqual(response.status_code, 200) + course_goals = response.data.get('course_goals') + self.assertEqual(course_goals['goal_options'], []) + course_tools = response.data.get('course_tools') self.assertEqual(len(course_tools), 0) @@ -170,3 +184,24 @@ class OutlineTabTestViews(BaseCourseHomeTests): html = '
Course expired HTML
' gen_html.return_value = html self.assertEqual(self.client.get(self.url).data['course_expired_html'], html) + + @ENABLE_COURSE_GOALS.override(active=True) + @COURSE_HOME_MICROFRONTEND.override(active=True) + @COURSE_HOME_MICROFRONTEND_OUTLINE_TAB.override(active=True) + def test_post_course_goal(self): + CourseEnrollment.enroll(self.user, self.course.id, CourseMode.AUDIT) + + post_data = { + 'course_id': self.course.id, + 'goal_key': 'certify' + } + post_course_goal_response = self.client.post(reverse('course-home-save-course-goal'), post_data) + self.assertEqual(post_course_goal_response.status_code, 200) + + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + + course_goals = response.data.get('course_goals') + selected_goal = course_goals['selected_goal'] + self.assertIsNotNone(selected_goal) + self.assertEqual(selected_goal['key'], 'certify') diff --git a/lms/djangoapps/course_home_api/outline/v1/views.py b/lms/djangoapps/course_home_api/outline/v1/views.py index 64e56750b8..75c016264c 100644 --- a/lms/djangoapps/course_home_api/outline/v1/views.py +++ b/lms/djangoapps/course_home_api/outline/v1/views.py @@ -7,6 +7,7 @@ from django.urls import reverse from django.utils.translation import gettext as _ from edx_django_utils import monitoring as monitoring_utils 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 from rest_framework.decorators import api_view, authentication_classes, permission_classes from rest_framework.exceptions import APIException, ParseError @@ -19,8 +20,11 @@ from completion.utilities import get_key_to_last_completed_block from course_modes.models import CourseMode from lms.djangoapps.course_api.blocks.transformers.blocks_api import BlocksAPITransformer from lms.djangoapps.course_blocks.api import get_course_block_access_transformers, get_course_blocks +from lms.djangoapps.course_goals.api import (add_course_goal, get_course_goal, get_course_goal_text, + has_course_goal_permission, valid_course_goals_ordered) from lms.djangoapps.course_home_api.outline.v1.serializers import OutlineTabSerializer -from lms.djangoapps.course_home_api.toggles import course_home_mfe_dates_tab_is_active, course_home_mfe_outline_tab_is_active +from lms.djangoapps.course_home_api.toggles import (course_home_mfe_dates_tab_is_active, + course_home_mfe_outline_tab_is_active) from lms.djangoapps.course_home_api.utils import get_microfrontend_url from lms.djangoapps.courseware.access import has_access from lms.djangoapps.courseware.context_processor import user_timezone_locale_prefs @@ -47,6 +51,12 @@ class UnableToDismissWelcomeMessage(APIException): default_code = 'unable_to_dismiss_welcome_message' +class UnableToSaveCourseGoal(APIException): + status_code = 400 + default_detail = 'Unable to save course goal' + default_code = 'unable_to_save_course_goal' + + class OutlineTabView(RetrieveAPIView): """ **Use Cases** @@ -73,6 +83,11 @@ class OutlineTabView(RetrieveAPIView): xBlock on the web LMS. children: (list) If the block has child blocks, a list of IDs of the child blocks. + course_goals: + goal_options: (list) A list of goals where each goal is represented as a tuple (goal_key, goal_string) + selected_goal: + key: (str) The unique id given to the user's selected goal. + text: (str) The display text for the user's selected goal. course_tools: List of serialized Course Tool objects. Each serialization has the following fields: analytics_id: (str) The unique id given to the tool. title: (str) The display title of the tool. @@ -209,9 +224,32 @@ class OutlineTabView(RetrieveAPIView): 'user_timezone': user_timezone, } + # Only show the set course goal message for enrolled, unverified + # users in a course that allows for verified statuses. + is_already_verified = CourseEnrollment.is_enrolled_as_verified(request.user, course_key) + if (not is_already_verified and + has_course_goal_permission(request, course_key_string, {'is_enrolled': is_enrolled})): + course_goals = { + 'goal_options': valid_course_goals_ordered(include_unsure=True), + 'selected_goal': None + } + + selected_goal = get_course_goal(request.user, course_key) + if selected_goal: + course_goals['selected_goal'] = { + 'key': selected_goal.goal_key, + 'text': get_course_goal_text(selected_goal.goal_key), + } + else: + course_goals = { + 'goal_options': [], + 'selected_goal': None + } + data = { 'course_blocks': course_blocks, 'course_expired_html': course_expired_html, + 'course_goals': course_goals, 'course_tools': course_tools, 'dates_widget': dates_widget, 'enroll_alert': enroll_alert, @@ -233,7 +271,7 @@ class OutlineTabView(RetrieveAPIView): def dismiss_welcome_message(request): course_id = request.data.get('course_id', None) - # If body doesnt contain 'course_id', return 400 to client. + # If body doesn't contain 'course_id', return 400 to client. if not course_id: raise ParseError(_("'course_id' is required.")) @@ -247,3 +285,29 @@ def dismiss_welcome_message(request): return Response({'message': _('Welcome message successfully dismissed.')}) except Exception: raise UnableToDismissWelcomeMessage + + +# Another version of this endpoint exists in ../course_goals/views.py +@api_view(['POST']) +@authentication_classes((JwtAuthentication, SessionAuthenticationAllowInactiveUser,)) +@permission_classes((IsAuthenticated,)) +def save_course_goal(request): + course_id = request.data.get('course_id', None) + goal_key = request.data.get('goal_key', None) + + # If body doesn't contain 'course_id', return 400 to client. + if not course_id: + raise ParseError(_("'course_id' is required.")) + + # If body doesn't contain 'goal', return 400 to client. + if not goal_key: + raise ParseError(_("'goal_key' is required.")) + + try: + add_course_goal(request.user, course_id, goal_key) + return Response({ + 'header': _('Your course goal has been successfully set.'), + 'message': _('Course goal updated successfully.'), + }) + except Exception: + raise UnableToSaveCourseGoal diff --git a/lms/djangoapps/course_home_api/urls.py b/lms/djangoapps/course_home_api/urls.py index 371e17e8ec..83bf406a64 100644 --- a/lms/djangoapps/course_home_api/urls.py +++ b/lms/djangoapps/course_home_api/urls.py @@ -8,7 +8,7 @@ from django.urls import re_path from lms.djangoapps.course_home_api.dates.v1.views import DatesTabView from lms.djangoapps.course_home_api.course_metadata.v1.views import CourseHomeMetadataView -from lms.djangoapps.course_home_api.outline.v1.views import OutlineTabView, dismiss_welcome_message +from lms.djangoapps.course_home_api.outline.v1.views import OutlineTabView, dismiss_welcome_message, save_course_goal from lms.djangoapps.course_home_api.progress.v1.views import ProgressTabView urlpatterns = [] @@ -48,6 +48,14 @@ urlpatterns += [ ), ] +urlpatterns += [ + re_path( + r'v1/save_course_goal', + save_course_goal, + name='course-home-save-course-goal' + ), +] + # Progress Tab URLs urlpatterns += [ re_path( diff --git a/openedx/features/course_experience/api/v1/views.py b/openedx/features/course_experience/api/v1/views.py index 62249dedca..676f2e2d5a 100644 --- a/openedx/features/course_experience/api/v1/views.py +++ b/openedx/features/course_experience/api/v1/views.py @@ -57,9 +57,9 @@ def reset_course_deadlines(request): return Response({ 'body': format_html('{}', body_link, _('View all dates')), - 'header': format_html( - '
{}
', _('Your due dates have been successfully shifted to help you stay on track.') - ), + 'header': _('Your due dates have been successfully shifted to help you stay on track.'), + 'link': body_link, + 'link_text': _('View all dates'), 'message': _('Deadlines successfully reset.'), }) except Exception: