diff --git a/cms/djangoapps/contentstore/tests/test_transcripts_utils.py b/cms/djangoapps/contentstore/tests/test_transcripts_utils.py index e92e1f937adf273c70d6ba1d54e74966434cb6d7..4e9de8075f13c0851450aed0f1186274b5f11213 100644 --- a/cms/djangoapps/contentstore/tests/test_transcripts_utils.py +++ b/cms/djangoapps/contentstore/tests/test_transcripts_utils.py @@ -156,7 +156,7 @@ class TestSaveSubsToStore(SharedModuleStoreTestCase): def test_save_unjsonable_subs_to_store(self): """ - Assures that subs, that can't be dumped, can't be found later. + Ensures that subs, that can't be dumped, can't be found later. """ with self.assertRaises(NotFoundError): contentstore().find(self.content_location_unjsonable) diff --git a/lms/djangoapps/course_goals/__init__.py b/lms/djangoapps/course_goals/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lms/djangoapps/course_goals/api.py b/lms/djangoapps/course_goals/api.py new file mode 100644 index 0000000000000000000000000000000000000000..b52c1794faf7b0520db87875ac4e731baed1dbb5 --- /dev/null +++ b/lms/djangoapps/course_goals/api.py @@ -0,0 +1,76 @@ +""" +Course Goals Python API +""" +from enum import Enum +from opaque_keys.edx.keys import CourseKey +from django.utils.translation import ugettext as _ +from openedx.core.djangolib.markup import Text + +from .models import CourseGoal + + +def add_course_goal(user, course_id, goal_key): + """ + Add a new course goal for the provided user and course. + + Arguments: + user: The user that is setting the goal + course_id (string): The id for the course the goal refers to + goal_key (string): The goal key that maps to one of the + enumerated goal keys from CourseGoalOption. + + """ + # Create and save a new course goal + course_key = CourseKey.from_string(str(course_id)) + new_goal = CourseGoal(user=user, course_key=course_key, goal_key=goal_key) + new_goal.save() + + +def get_course_goal(user, course_key): + """ + Given a user and a course_key, return their course goal. + + If a course goal does not exist, returns None. + """ + course_goals = CourseGoal.objects.filter(user=user, course_key=course_key) + return course_goals[0] if course_goals else None + + +def remove_course_goal(user, course_key): + """ + Given a user and a course_key, remove the course goal. + """ + course_goal = get_course_goal(user, course_key) + if course_goal: + course_goal.delete() + + +class CourseGoalOption(Enum): + """ + Types of goals that a user can select. + + These options are set to a string goal key so that they can be + referenced elsewhere in the code when necessary. + """ + CERTIFY = 'certify' + COMPLETE = 'complete' + EXPLORE = 'explore' + UNSURE = 'unsure' + + @classmethod + def get_course_goal_keys(self): + return [key.value for key in self] + + +def get_goal_text(goal_option): + """ + This function is used to translate the course goal option into + a translated, user-facing string to be used to represent that + particular goal. + """ + return { + CourseGoalOption.CERTIFY.value: Text(_('Earn a certificate')), + CourseGoalOption.COMPLETE.value: Text(_('Complete the course')), + CourseGoalOption.EXPLORE.value: Text(_('Explore the course')), + CourseGoalOption.UNSURE.value: Text(_('Not sure yet')), + }[goal_option] diff --git a/lms/djangoapps/course_goals/migrations/0001_initial.py b/lms/djangoapps/course_goals/migrations/0001_initial.py new file mode 100644 index 0000000000000000000000000000000000000000..bcf13e339e9696d763095205a2fb8553557b8aef --- /dev/null +++ b/lms/djangoapps/course_goals/migrations/0001_initial.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +import openedx.core.djangoapps.xmodule_django.models +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='CourseGoal', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('course_key', openedx.core.djangoapps.xmodule_django.models.CourseKeyField(max_length=255, db_index=True)), + ('goal_key', models.CharField(default=b'unsure', max_length=100, choices=[(b'certify', 'Earn a certificate.'), (b'complete', 'Complete the course.'), (b'explore', 'Explore the course.'), (b'unsure', 'Not sure yet.')])), + ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AlterUniqueTogether( + name='coursegoal', + unique_together=set([('user', 'course_key')]), + ), + ] diff --git a/lms/djangoapps/course_goals/migrations/__init__.py b/lms/djangoapps/course_goals/migrations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lms/djangoapps/course_goals/models.py b/lms/djangoapps/course_goals/models.py new file mode 100644 index 0000000000000000000000000000000000000000..fac52a729a7a28f338d260a4ea56593f5c671676 --- /dev/null +++ b/lms/djangoapps/course_goals/models.py @@ -0,0 +1,35 @@ +""" +Course Goals Models +""" +from django.contrib.auth.models import User +from django.db import models +from openedx.core.djangoapps.xmodule_django.models import CourseKeyField + + +class CourseGoal(models.Model): + """ + Represents a course goal set by a user on the course home page. + + The goal_key represents the goal key that maps to a translated + string through using the CourseGoalOption class. + """ + GOAL_KEY_CHOICES = ( + ('certify', 'Earn a certificate.'), + ('complete', 'Complete the course.'), + ('explore', 'Explore the course.'), + ('unsure', 'Not sure yet.'), + ) + + user = models.ForeignKey(User, blank=False) + course_key = CourseKeyField(max_length=255, db_index=True) + goal_key = models.CharField(max_length=100, choices=GOAL_KEY_CHOICES, default='unsure') + + def __unicode__(self): + return 'CourseGoal: {user} set goal to {goal} for course {course}'.format( + user=self.user.username, + course=self.course_key, + goal_key=self.goal_key, + ) + + class Meta: + unique_together = ("user", "course_key") diff --git a/lms/djangoapps/course_goals/signals.py b/lms/djangoapps/course_goals/signals.py new file mode 100644 index 0000000000000000000000000000000000000000..2957c2eb274e27368c9b2281c49ef482a10db8d2 --- /dev/null +++ b/lms/djangoapps/course_goals/signals.py @@ -0,0 +1,19 @@ +""" +Course Goals Signals +""" +from django.db.models.signals import post_save +from django.dispatch import receiver +from eventtracking import tracker + +from .models import CourseGoal + + +@receiver(post_save, sender=CourseGoal, dispatch_uid="emit_course_goal_event") +def emit_course_goal_event(sender, instance, **kwargs): + name = 'edx.course.goal.added' if kwargs.get('created', False) else 'edx.course.goal.updated' + tracker.emit( + name, + { + 'goal_key': instance.goal_key, + } + ) diff --git a/lms/djangoapps/course_goals/tests/__init__.py b/lms/djangoapps/course_goals/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lms/djangoapps/course_goals/tests/test_api.py b/lms/djangoapps/course_goals/tests/test_api.py new file mode 100644 index 0000000000000000000000000000000000000000..1c20dd7ba37a0e780c531bbfd981abfbc2244968 --- /dev/null +++ b/lms/djangoapps/course_goals/tests/test_api.py @@ -0,0 +1,62 @@ +""" +Unit tests for course_goals.api methods. +""" + +from django.contrib.auth.models import User +from django.core.urlresolvers import reverse +from lms.djangoapps.course_goals.models import CourseGoal +from rest_framework.test import APIClient +from student.models import CourseEnrollment +from track.tests import EventTrackingTestCase +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + +TEST_PASSWORD = 'test' + + +class TestCourseGoalsAPI(EventTrackingTestCase, SharedModuleStoreTestCase): + """ + Testing the Course Goals API. + """ + + def setUp(self): + # Create a course with a verified track + super(TestCourseGoalsAPI, self).setUp() + self.course = CourseFactory.create(emit_signals=True) + + self.user = User.objects.create_user('john', 'lennon@thebeatles.com', 'password') + CourseEnrollment.enroll(self.user, self.course.id) + + self.client = APIClient(enforce_csrf_checks=True) + self.client.login(username=self.user.username, password=self.user.password) + self.client.force_authenticate(user=self.user) + + self.apiUrl = reverse('course_goals_api:v0:course_goal-list') + + def test_add_valid_goal(self): + """ Ensures a correctly formatted post succeeds. """ + response = self.post_course_goal(valid=True) + self.assert_events_emitted() + self.assertEqual(response.status_code, 201) + self.assertEqual(len(CourseGoal.objects.filter(user=self.user, course_key=self.course.id)), 1) + + def test_add_invalid_goal(self): + """ Ensures a correctly formatted post succeeds. """ + response = self.post_course_goal(valid=False) + self.assertEqual(response.status_code, 400) + self.assertEqual(len(CourseGoal.objects.filter(user=self.user, course_key=self.course.id)), 0) + + def post_course_goal(self, valid=True, goal_key='certify'): + """ + Sends a post request to set a course goal and returns the response. + """ + goal_key = goal_key if valid else 'invalid' + response = self.client.post( + self.apiUrl, + { + 'goal_key': goal_key, + 'course_key': self.course.id, + 'user': self.user.username, + }, + ) + return response diff --git a/lms/djangoapps/course_goals/urls.py b/lms/djangoapps/course_goals/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..cb87b3db771ef0a18811456c88b53cc930038b7e --- /dev/null +++ b/lms/djangoapps/course_goals/urls.py @@ -0,0 +1,15 @@ +""" +Course Goals URLs +""" +from django.conf.urls import include, patterns, url +from rest_framework import routers + +from .views import CourseGoalViewSet + +router = routers.DefaultRouter() +router.register(r'course_goals', CourseGoalViewSet, base_name='course_goal') + +urlpatterns = patterns( + '', + url(r'^v0/', include(router.urls, namespace='v0')), +) diff --git a/lms/djangoapps/course_goals/views.py b/lms/djangoapps/course_goals/views.py new file mode 100644 index 0000000000000000000000000000000000000000..513d52c44056845f1122f419c00a69ed094a617b --- /dev/null +++ b/lms/djangoapps/course_goals/views.py @@ -0,0 +1,92 @@ +""" +Course Goals Views - includes REST API +""" +from django.contrib.auth import get_user_model +from django.db.models.signals import post_save +from django.dispatch import receiver +from edx_rest_framework_extensions.authentication import JwtAuthentication +from eventtracking import tracker +from opaque_keys.edx.keys import CourseKey +from openedx.core.lib.api.permissions import IsStaffOrOwner +from rest_framework import permissions, serializers, viewsets +from rest_framework.authentication import SessionAuthentication + +from .api import CourseGoalOption +from .models import CourseGoal + +User = get_user_model() + + +class CourseGoalSerializer(serializers.ModelSerializer): + """ + Serializes CourseGoal models. + """ + user = serializers.SlugRelatedField(slug_field='username', queryset=User.objects.all()) + + class Meta: + model = CourseGoal + fields = ('user', 'course_key', 'goal_key') + + def validate_goal_key(self, value): + """ + Ensure that the goal_key is valid. + """ + if value not in CourseGoalOption.get_course_goal_keys(): + raise serializers.ValidationError( + 'Provided goal key, {goal_key}, is not a valid goal key (options= {goal_options}).'.format( + goal_key=value, + goal_options=[option.value for option in CourseGoalOption], + ) + ) + return value + + def validate_course_key(self, value): + """ + Ensure that the course_key is valid. + """ + course_key = CourseKey.from_string(value) + if not course_key: + raise serializers.ValidationError( + 'Provided course_key ({course_key}) does not map to a course.'.format( + course_key=course_key + ) + ) + return course_key + + +class CourseGoalViewSet(viewsets.ModelViewSet): + """ + API calls to create and retrieve a course goal. + + **Use Case** + * Create a new goal for a user. + + Http400 is returned if the format of the request is not correct, + the course_id or goal is invalid or cannot be found. + + * Retrieve goal for a user and a particular course. + + Http400 is returned if the format of the request is not correct, + or the course_id is invalid or cannot be found. + + **Example Requests** + GET /api/course_goals/v0/course_goals/ + POST /api/course_goals/v0/course_goals/ + Request data: {"course_key": <course-key>, "goal_key": "<goal-key>", "user": "<username>"} + + """ + authentication_classes = (JwtAuthentication, SessionAuthentication,) + permission_classes = (permissions.IsAuthenticated, IsStaffOrOwner,) + queryset = CourseGoal.objects.all() + serializer_class = CourseGoalSerializer + + +@receiver(post_save, sender=CourseGoal, dispatch_uid="emit_course_goals_event") +def emit_course_goal_event(sender, instance, **kwargs): + name = 'edx.course.goal.added' if kwargs.get('created', False) else 'edx.course.goal.updated' + tracker.emit( + name, + { + 'goal_key': instance.goal_key, + } + ) diff --git a/lms/envs/common.py b/lms/envs/common.py index beb140bea528e8f541e930adae23771f0dc202c5..b9863b88c9e946d46355b38964001c80d71fefef 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -401,6 +401,9 @@ FEATURES = { # Whether the bulk enrollment view is enabled. 'ENABLE_BULK_ENROLLMENT_VIEW': False, + + # Whether course goals is enabled. + 'ENABLE_COURSE_GOALS': True, } # Settings for the course reviews tool template and identification key, set either to None to disable course reviews @@ -2245,6 +2248,9 @@ INSTALLED_APPS = [ 'openedx.core.djangoapps.waffle_utils', 'openedx.core.djangoapps.schedules.apps.SchedulesConfig', + # Course Goals + 'lms.djangoapps.course_goals', + # Features 'openedx.features.course_bookmarks', 'openedx.features.course_experience', diff --git a/lms/static/sass/features/_course-experience.scss b/lms/static/sass/features/_course-experience.scss index 48060b0db977d36675a5184cf7f3b203439d0b3c..fff4702801d65c1ee49995ce0b3c4caaf6126e61 100644 --- a/lms/static/sass/features/_course-experience.scss +++ b/lms/static/sass/features/_course-experience.scss @@ -15,11 +15,12 @@ } .message-content { + @include margin(0, 0, $baseline, $baseline); position: relative; border: 1px solid $lms-border-color; - margin: 0 $baseline $baseline/2; - padding: $baseline/2 $baseline; + padding: $baseline; border-radius: $baseline/4; + width: calc(100% - 90px); @media (max-width: $grid-breakpoints-md) { width: 100%; @@ -30,7 +31,7 @@ &::before { @include left(0); - bottom: 35%; + top: 25px; border: solid transparent; height: 0; width: 0; @@ -58,13 +59,49 @@ .message-header { font-weight: $font-semibold; - margin-bottom: $baseline/4; + margin-bottom: $baseline/2; + width: calc(100% - 40px) } a { font-weight: $font-semibold; text-decoration: underline; } + .dismiss { + @include right($baseline/4); + top: $baseline/4; + position: absolute; + cursor: pointer; + color: $black-t3; + + &:hover { + color: $black-t2; + } + } + // Course Goal Styling + .goal-options-container { + margin-top: $baseline; + text-align: center; + + .goal-option { + text-decoration: none; + font-size: font-size(x-small); + padding: $baseline/2; + + &.dismissible { + @include right($baseline/4); + position: absolute; + top: $baseline/2; + font-size: font-size(small); + color: $uxpl-blue-base; + cursor: pointer; + + &:hover { + color: $black-t2; + } + } + } + } } } diff --git a/lms/templates/navigation/navigation.html b/lms/templates/navigation/navigation.html index 2f1972b527277fc53a91f51437985dcc5bb3a97e..26d43a9e5f1a09ad0f2854fd4c2ce3ab4ba1c0b6 100644 --- a/lms/templates/navigation/navigation.html +++ b/lms/templates/navigation/navigation.html @@ -50,7 +50,7 @@ site_status_msg = get_site_status_msg(course_id) </%block> % if uses_bootstrap: - <header class="navigation-container header-global ${"slim" if course else ""}"> + <header class="navigation-container header-global ${'slim' if course else ''}"> <nav class="navbar navbar-expand-lg navbar-light"> <%include file="bootstrap/navbar-logo-header.html" args="online_help_token=online_help_token"/> <button class="navbar-toggler navbar-toggler-right mt-2" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation"> diff --git a/lms/urls.py b/lms/urls.py index 8da75b2aedd4f4830c10051baec7d5c87f10c1b4..c37d39bb18cf25d97bbec24ab5da8043e7ee5402 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -829,6 +829,11 @@ urlpatterns += ( url(r'^commerce/', include('commerce.urls', namespace='commerce')), ) +# Course goals +urlpatterns += ( + url(r'^api/course_goals/', include('lms.djangoapps.course_goals.urls', namespace='course_goals_api')), +) + # Embargo if settings.FEATURES.get('EMBARGO'): urlpatterns += ( diff --git a/openedx/core/lib/api/permissions.py b/openedx/core/lib/api/permissions.py index c71381f2cd3743fdd52afd624078f04d9d941606..6bbf3eef1540d2df0119cf943d98b21a60ec1891 100644 --- a/openedx/core/lib/api/permissions.py +++ b/openedx/core/lib/api/permissions.py @@ -166,4 +166,5 @@ class IsStaffOrOwner(permissions.BasePermission): return user.is_staff \ or (user.username == request.GET.get('username')) \ or (user.username == getattr(request, 'data', {}).get('username')) \ + or (user.username == getattr(request, 'data', {}).get('user')) \ or (user.username == getattr(view, 'kwargs', {}).get('username')) diff --git a/openedx/features/course_experience/__init__.py b/openedx/features/course_experience/__init__.py index 31f994e4b4643200d310ba65a110acc97abe97ad..8c9980f5788f219e24bc56abc56c9b4d07c1af83 100644 --- a/openedx/features/course_experience/__init__.py +++ b/openedx/features/course_experience/__init__.py @@ -16,15 +16,18 @@ COURSE_OUTLINE_PAGE_FLAG = CourseWaffleFlag(WAFFLE_FLAG_NAMESPACE, 'course_outli # Waffle flag to enable a single unified "Course" tab. UNIFIED_COURSE_TAB_FLAG = CourseWaffleFlag(WAFFLE_FLAG_NAMESPACE, 'unified_course_tab') -# Waffle flag to enable the sock on the footer of the home and courseware pages +# Waffle flag to enable the sock on the footer of the home and courseware pages. DISPLAY_COURSE_SOCK_FLAG = CourseWaffleFlag(WAFFLE_FLAG_NAMESPACE, 'display_course_sock') -# Waffle flag to let learners access a course before its start date +# Waffle flag to let learners access a course before its start date. COURSE_PRE_START_ACCESS_FLAG = CourseWaffleFlag(WAFFLE_FLAG_NAMESPACE, 'pre_start_access') -# Waffle flag to enable a review page link from the unified home page +# Waffle flag to enable a review page link from the unified home page. SHOW_REVIEWS_TOOL_FLAG = CourseWaffleFlag(WAFFLE_FLAG_NAMESPACE, 'show_reviews_tool') +# Waffle flag to enable the setting of course goals. +ENABLE_COURSE_GOALS = CourseWaffleFlag(WAFFLE_FLAG_NAMESPACE, 'enable_course_goals') + SHOW_UPGRADE_MSG_ON_COURSE_HOME = CourseWaffleFlag(WAFFLE_FLAG_NAMESPACE, 'show_upgrade_msg_on_course_home') # Waffle flag to switch between the 'welcome message' and 'latest update' on the course home page. diff --git a/openedx/features/course_experience/static/course_experience/js/CourseGoals.js b/openedx/features/course_experience/static/course_experience/js/CourseGoals.js new file mode 100644 index 0000000000000000000000000000000000000000..644d15802856f2a2e07ae2fce594a4d213ac0669 --- /dev/null +++ b/openedx/features/course_experience/static/course_experience/js/CourseGoals.js @@ -0,0 +1,40 @@ +/* globals gettext */ + +export class CourseGoals { // eslint-disable-line import/prefer-default-export + + constructor(options) { + $('.goal-option').click((e) => { + const goalKey = $(e.target).data().choice; + $.ajax({ + method: 'POST', + url: options.goalApiUrl, + headers: { 'X-CSRFToken': $.cookie('csrftoken') }, + data: { + goal_key: goalKey, + course_key: options.courseId, + user: options.username, + }, + dataType: 'json', + success: () => { + // LEARNER-2522 will address the success message + const successMsg = gettext('Thank you for setting your course goal!'); + // xss-lint: disable=javascript-jquery-html + $('.message-content').html(`<div class="success-message">${successMsg}</div>`); + }, + error: () => { + // LEARNER-2522 will address the error message + const errorMsg = gettext('There was an error in setting your goal, please reload the page and try again.'); // eslint-disable-line max-len + // xss-lint: disable=javascript-jquery-html + $('.message-content').html(`<div class="error-message"> ${errorMsg} </div>`); + }, + }); + }); + + // Allow goal selection with an enter press for accessibility purposes + $('.goal-option').keyup((e) => { + if (e.which === 13) { + $(e.target).trigger('click'); + } + }); + } +} diff --git a/openedx/features/course_experience/static/course_experience/js/CourseHome.js b/openedx/features/course_experience/static/course_experience/js/CourseHome.js index 523c4c54181ce0b3ac754c41ad1d238a0c505e26..7b67781f1931e656ee61b3530e64f7a3da66a91f 100644 --- a/openedx/features/course_experience/static/course_experience/js/CourseHome.js +++ b/openedx/features/course_experience/static/course_experience/js/CourseHome.js @@ -30,6 +30,18 @@ export class CourseHome { // eslint-disable-line import/prefer-default-export ); }); + // Dismissibility for in course messages + $(document.body).on('click', '.course-message .dismiss', (event) => { + $(event.target).closest('.course-message').hide(); + }); + + // Allow dismiss on enter press for accessibility purposes + $(document.body).on('keyup', '.course-message .dismiss', (event) => { + if (event.which === 13) { + $(event.target).trigger('click'); + } + }); + $(document).ready(() => { this.configureUpgradeMessage(); }); diff --git a/openedx/features/course_experience/static/course_experience/js/CourseSock.js b/openedx/features/course_experience/static/course_experience/js/CourseSock.js index c4a321e4c811ba0b7d19a74632ec51ad471fa49d..9e9126e39e3dbc45262d8992dec4b68d76431039 100644 --- a/openedx/features/course_experience/static/course_experience/js/CourseSock.js +++ b/openedx/features/course_experience/static/course_experience/js/CourseSock.js @@ -19,7 +19,7 @@ export class CourseSock { // eslint-disable-line import/prefer-default-export const startFixed = $verificationSock.offset().top + 320; const endFixed = (startFixed + $verificationSock.height()) - 220; - // Assure update button stays in sock even when max-width is exceeded + // Ensure update button stays in sock even when max-width is exceeded const distLeft = ($verificationSock.offset().left + $verificationSock.width()) - ($upgradeToVerifiedButton.width() + 22); diff --git a/openedx/features/course_experience/templates/course_experience/course-messages-fragment.html b/openedx/features/course_experience/templates/course_experience/course-messages-fragment.html index 1cf6f94bd1595e2b4b2bd6335d5ffdc66af814d8..18df52b2ee76779022640509ee9f34c1de323d54 100644 --- a/openedx/features/course_experience/templates/course_experience/course-messages-fragment.html +++ b/openedx/features/course_experience/templates/course_experience/course-messages-fragment.html @@ -5,6 +5,8 @@ <%! from django.utils.translation import get_language_bidi +from django.utils.translation import ugettext as _ +from openedx.core.djangolib.js_utils import js_escaped_string from openedx.core.djangolib.markup import HTML from openedx.features.course_experience import CourseHomeMessages %> @@ -17,14 +19,22 @@ is_rtl = get_language_bidi() % for message in course_home_messages: <div class="course-message grid-manual"> % if not is_rtl: - <img class="message-author col col-2" src="${static.url(image_src)}"/> + <img class="message-author" alt="${_('Course message author')}" role="none" src="${static.url(image_src)}"/> % endif - <div class="message-content col col-9"> + <div class="message-content"> ${HTML(message.message_html)} </div> % if is_rtl: - <img class="message-author col col-2" src="${static.url(image_src)}"/> + <img class="message-author" alt="${_('Course message author')}" role="none" src="${static.url(image_src)}"/> % endif </div> % endfor % endif + +<%static:webpack entry="CourseGoals"> + new CourseGoals({ + goalApiUrl: "${goal_api_url | n, js_escaped_string}", + courseId: "${course_id | n, js_escaped_string}", + username: "${username | n, js_escaped_string}", + }); +</%static:webpack> diff --git a/openedx/features/course_experience/tests/views/test_course_home.py b/openedx/features/course_experience/tests/views/test_course_home.py index d655d495d8834f5dd97beb1bf88f79f171fd02f8..a6a118d10e3f8ccee099f7abff5247717b52e572 100644 --- a/openedx/features/course_experience/tests/views/test_course_home.py +++ b/openedx/features/course_experience/tests/views/test_course_home.py @@ -16,6 +16,7 @@ from waffle.testutils import override_flag from commerce.models import CommerceConfiguration from commerce.utils import EcommerceService +from lms.djangoapps.course_goals.api import add_course_goal, remove_course_goal from course_modes.models import CourseMode from courseware.tests.factories import StaffFactory from openedx.core.djangoapps.waffle_utils.testutils import WAFFLE_TABLES, override_waffle_flag @@ -25,14 +26,14 @@ from openedx.features.course_experience import ( UNIFIED_COURSE_TAB_FLAG ) from student.models import CourseEnrollment -from student.tests.factories import UserFactory +from student.tests.factories import UserFactory, CourseEnrollmentFactory from util.date_utils import strftime_localized from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.tests.django_utils import CourseUserType, ModuleStoreTestCase, SharedModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, check_mongo_calls from .helpers import add_course_mode from .test_course_updates import create_course_update, remove_course_updates -from ... import COURSE_PRE_START_ACCESS_FLAG +from ... import COURSE_PRE_START_ACCESS_FLAG, ENABLE_COURSE_GOALS TEST_PASSWORD = 'test' TEST_CHAPTER_NAME = 'Test Chapter' @@ -43,6 +44,8 @@ TEST_COURSE_HOME_MESSAGE = 'course-message' TEST_COURSE_HOME_MESSAGE_ANONYMOUS = '/login' TEST_COURSE_HOME_MESSAGE_UNENROLLED = 'Enroll now' TEST_COURSE_HOME_MESSAGE_PRE_START = 'Course starts in' +TEST_COURSE_GOAL_OPTIONS = 'goal-options-container' +COURSE_GOAL_DISMISS_OPTION = 'unsure' QUERY_COUNT_TABLE_BLACKLIST = WAFFLE_TABLES @@ -170,7 +173,7 @@ class TestCourseHomePage(CourseHomePageTestCase): course_home_url(self.course) # Fetch the view and verify the query counts - with self.assertNumQueries(41, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST): + with self.assertNumQueries(44, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST): with check_mongo_calls(4): url = course_home_url(self.course) self.client.get(url) @@ -375,11 +378,13 @@ class TestCourseHomePageAccess(CourseHomePageTestCase): self.assertContains(response, TEST_COURSE_HOME_MESSAGE) self.assertContains(response, TEST_COURSE_HOME_MESSAGE_UNENROLLED) - # Verify that enrolled users are not shown a message when enrolled and course has begun + # Verify that enrolled users are not shown any state warning message when enrolled and course has begun. CourseEnrollment.enroll(user, self.course.id) url = course_home_url(self.course) response = self.client.get(url) - self.assertNotContains(response, TEST_COURSE_HOME_MESSAGE) + self.assertNotContains(response, TEST_COURSE_HOME_MESSAGE_ANONYMOUS) + self.assertNotContains(response, TEST_COURSE_HOME_MESSAGE_UNENROLLED) + self.assertNotContains(response, TEST_COURSE_HOME_MESSAGE_PRE_START) # Verify that enrolled users are shown 'days until start' message before start date future_course = self.create_future_course() @@ -389,6 +394,50 @@ class TestCourseHomePageAccess(CourseHomePageTestCase): self.assertContains(response, TEST_COURSE_HOME_MESSAGE) self.assertContains(response, TEST_COURSE_HOME_MESSAGE_PRE_START) + @override_waffle_flag(UNIFIED_COURSE_TAB_FLAG, active=True) + @override_waffle_flag(COURSE_PRE_START_ACCESS_FLAG, active=True) + @override_waffle_flag(ENABLE_COURSE_GOALS, active=True) + def test_course_goals(self): + """ + Ensure that the following five use cases work as expected. + + 1) Unenrolled users are not shown the set course goal message. + 2) Enrolled users are shown the set course goal message if they have not yet set a course goal. + 3) Enrolled users are not shown the set course goal message if they have set a course goal. + 4) Enrolled and verified users are not shown the set course goal message. + 5) Enrolled users are not shown the set course goal message in a course that cannot be verified. + """ + # Create a course with a verified track. + verifiable_course = CourseFactory.create() + add_course_mode(verifiable_course, upgrade_deadline_expired=False) + + # Verify that unenrolled users are not shown the set course goal message. + user = self.create_user_for_course(verifiable_course, CourseUserType.UNENROLLED) + response = self.client.get(course_home_url(verifiable_course)) + self.assertNotContains(response, TEST_COURSE_GOAL_OPTIONS) + + # Verify that enrolled users are shown the set course goal message in a verified course. + CourseEnrollment.enroll(user, verifiable_course.id) + response = self.client.get(course_home_url(verifiable_course)) + self.assertContains(response, TEST_COURSE_GOAL_OPTIONS) + + # Verify that enrolled users that have set a course goal are not shown the set course goal message. + add_course_goal(user, verifiable_course.id, COURSE_GOAL_DISMISS_OPTION) + response = self.client.get(course_home_url(verifiable_course)) + self.assertNotContains(response, TEST_COURSE_GOAL_OPTIONS) + + # Verify that enrolled and verified users are not shown the set course goal message. + remove_course_goal(user, verifiable_course.id) + CourseEnrollment.enroll(user, verifiable_course.id, CourseMode.VERIFIED) + response = self.client.get(course_home_url(verifiable_course)) + self.assertNotContains(response, TEST_COURSE_GOAL_OPTIONS) + + # Verify that enrolled users are not shown the set course goal message in an audit only course. + audit_only_course = CourseFactory.create() + CourseEnrollment.enroll(user, audit_only_course.id) + response = self.client.get(course_home_url(audit_only_course)) + self.assertNotContains(response, TEST_COURSE_GOAL_OPTIONS) + class CourseHomeFragmentViewTests(ModuleStoreTestCase): CREATE_USER = False diff --git a/openedx/features/course_experience/tests/views/test_course_sock.py b/openedx/features/course_experience/tests/views/test_course_sock.py index 3d3beff4332a2b5f36d960afc2f84ad3b7eaa0b9..c4cebe8b1d29019baca7e3df715499c7b1ee0825 100644 --- a/openedx/features/course_experience/tests/views/test_course_sock.py +++ b/openedx/features/course_experience/tests/views/test_course_sock.py @@ -56,7 +56,7 @@ class TestCourseSockView(SharedModuleStoreTestCase): @override_waffle_flag(DISPLAY_COURSE_SOCK_FLAG, active=True) def test_standard_course(self): """ - Assure that a course that cannot be verified does + Ensure that a course that cannot be verified does not have a visible verification sock. """ response = self.client.get(course_home_url(self.standard_course)) @@ -65,7 +65,7 @@ class TestCourseSockView(SharedModuleStoreTestCase): @override_waffle_flag(DISPLAY_COURSE_SOCK_FLAG, active=True) def test_verified_course(self): """ - Assure that a course that can be verified has a + Ensure that a course that can be verified has a visible verification sock. """ response = self.client.get(course_home_url(self.verified_course)) @@ -74,7 +74,7 @@ class TestCourseSockView(SharedModuleStoreTestCase): @override_waffle_flag(DISPLAY_COURSE_SOCK_FLAG, active=True) def test_verified_course_updated_expired(self): """ - Assure that a course that has an expired upgrade + Ensure that a course that has an expired upgrade date does not display the verification sock. """ response = self.client.get(course_home_url(self.verified_course_update_expired)) @@ -83,7 +83,7 @@ class TestCourseSockView(SharedModuleStoreTestCase): @override_waffle_flag(DISPLAY_COURSE_SOCK_FLAG, active=True) def test_verified_course_user_already_upgraded(self): """ - Assure that a user that has already upgraded to a + Ensure that a user that has already upgraded to a verified status cannot see the verification sock. """ response = self.client.get(course_home_url(self.verified_course_already_enrolled)) diff --git a/openedx/features/course_experience/views/course_home_messages.py b/openedx/features/course_experience/views/course_home_messages.py index c00cc82afb174ff777c6649a6af4705381a347f1..9b580c85828f12fa29844903e6bf4ee111d4792a 100644 --- a/openedx/features/course_experience/views/course_home_messages.py +++ b/openedx/features/course_experience/views/course_home_messages.py @@ -1,22 +1,30 @@ """ View logic for handling course messages. """ - -from babel.dates import format_date, format_timedelta +import math from datetime import datetime -from courseware.courses import get_course_with_access +from babel.dates import format_date, format_timedelta +from django.conf import settings +from django.contrib import auth from django.template.loader import render_to_string from django.utils.http import urlquote_plus from django.utils.timezone import UTC -from django.utils.translation import get_language, to_locale from django.utils.translation import ugettext as _ -from openedx.core.djangolib.markup import Text, HTML +from django.utils.translation import get_language, to_locale from opaque_keys.edx.keys import CourseKey +from rest_framework.reverse import reverse from web_fragments.fragment import Fragment +from course_modes.models import CourseMode +from courseware.courses import get_course_with_access +from lms.djangoapps.course_goals.api import CourseGoalOption, get_course_goal, get_goal_text from openedx.core.djangoapps.plugin_api.views import EdxFragmentView +from openedx.core.djangolib.markup import HTML, Text from openedx.features.course_experience import CourseHomeMessages +from student.models import CourseEnrollment + +from .. import ENABLE_COURSE_GOALS class CourseHomeMessageFragmentView(EdxFragmentView): @@ -55,69 +63,140 @@ class CourseHomeMessageFragmentView(EdxFragmentView): } # Register the course home messages to be loaded on the page - self.register_course_home_messages(request, course, user_access, course_start_data) + _register_course_home_messages(request, course_id, user_access, course_start_data) # Grab the relevant messages course_home_messages = list(CourseHomeMessages.user_messages(request)) - # Return None if user is enrolled and course has begun - if user_access['is_enrolled'] and already_started: - return None + # Pass in the url used to set a course goal + goal_api_url = reverse('course_goals_api:v0:course_goal-list', request=request) # Grab the logo image_src = "course_experience/images/home_message_author.png" context = { 'course_home_messages': course_home_messages, + 'goal_api_url': goal_api_url, 'image_src': image_src, + 'course_id': course_id, + 'username': request.user.username, } html = render_to_string('course_experience/course-messages-fragment.html', context) return Fragment(html) - @staticmethod - def register_course_home_messages(request, course, user_access, course_start_data): - """ - Register messages to be shown in the course home content page. - """ - if user_access['is_anonymous']: - CourseHomeMessages.register_info_message( - request, - Text(_( - " {sign_in_link} or {register_link} and then enroll in this course." - )).format( - sign_in_link=HTML("<a href='/login?next={current_url}'>{sign_in_label}</a>").format( - sign_in_label=_("Sign in"), - current_url=urlquote_plus(request.path), - ), - register_link=HTML("<a href='/register?next={current_url}'>{register_label}</a>").format( - register_label=_("register"), - current_url=urlquote_plus(request.path), - ) + +def _register_course_home_messages(request, course_id, user_access, course_start_data): + """ + Register messages to be shown in the course home content page. + """ + course_key = CourseKey.from_string(course_id) + course = get_course_with_access(request.user, 'load', course_key) + if user_access['is_anonymous']: + CourseHomeMessages.register_info_message( + request, + Text(_( + " {sign_in_link} or {register_link} and then enroll in this course." + )).format( + sign_in_link=HTML("<a href='/login?next={current_url}'>{sign_in_label}</a>").format( + sign_in_label=_("Sign in"), + current_url=urlquote_plus(request.path), ), - title='You must be enrolled in the course to see course content.' + register_link=HTML("<a href='/register?next={current_url}'>{register_label}</a>").format( + register_label=_("register"), + current_url=urlquote_plus(request.path), + ) + ), + title=Text(_('You must be enrolled in the course to see course content.')) + ) + if not user_access['is_anonymous'] and not user_access['is_staff'] and not user_access['is_enrolled']: + CourseHomeMessages.register_info_message( + request, + Text(_( + "{open_enroll_link} Enroll now{close_enroll_link} to access the full course." + )).format( + open_enroll_link='', + close_enroll_link='' + ), + title=Text(_('Welcome to {course_display_name}')).format( + course_display_name=course.display_name + ) + ) + if user_access['is_enrolled'] and not course_start_data['already_started']: + CourseHomeMessages.register_info_message( + request, + Text(_( + "Don't forget to add a calendar reminder!" + )), + title=Text(_("Course starts in {days_until_start_string} on {course_start_date}.")).format( + days_until_start_string=course_start_data['days_until_start_string'], + course_start_date=course_start_data['course_start_date'] ) - if not user_access['is_anonymous'] and not user_access['is_staff'] and not user_access['is_enrolled']: - CourseHomeMessages.register_info_message( - request, - Text(_( - "{open_enroll_link} Enroll now{close_enroll_link} to access the full course." - )).format( - open_enroll_link='', - close_enroll_link='' + ) + + # Only show the set course goal message for enrolled, unverified + # users that have not yet set a goal in a course that allows for + # verified statuses. + has_verified_mode = CourseMode.has_verified_mode(CourseMode.modes_for_course_dict(unicode(course.id))) + is_already_verified = CourseEnrollment.is_enrolled_as_verified(request.user, course_key) + user_goal = get_course_goal(auth.get_user(request), course_key) if not request.user.is_anonymous() else None + if user_access['is_enrolled'] and has_verified_mode and not is_already_verified and not user_goal \ + and ENABLE_COURSE_GOALS.is_enabled(course_key) and settings.FEATURES.get('ENABLE_COURSE_GOALS'): + goal_choices_html = Text(_( + 'To start, set a course goal by selecting the option below that best describes ' + 'your learning plan. {goal_options_container}' + )).format( + goal_options_container=HTML('<div class="row goal-options-container">') + ) + + # Add the dismissible option for users that are unsure of their goal + goal_choices_html += Text( + '{initial_tag}{choice}{closing_tag}' + ).format( + initial_tag=HTML( + '<div tabindex="0" aria-label="{aria_label_choice}" class="goal-option dismissible" ' + 'data-choice="{goal_key}">' + ).format( + goal_key=CourseGoalOption.UNSURE.value, + aria_label_choice=Text(_("Set goal to: {choice}")).format( + choice=get_goal_text(CourseGoalOption.UNSURE.value) ), - title=Text('Welcome to {course_display_name}').format( - course_display_name=course.display_name - ) + ), + choice=Text(_('{choice}')).format( + choice=get_goal_text(CourseGoalOption.UNSURE.value), + ), + closing_tag=HTML('</div>'), + ) + + # Add the option to set a goal to earn a certificate, + # complete the course or explore the course + goal_options = [CourseGoalOption.CERTIFY.value, CourseGoalOption.COMPLETE.value, CourseGoalOption.EXPLORE.value] + for goal_key in goal_options: + goal_text = get_goal_text(goal_key) + goal_choices_html += HTML( + '{initial_tag}{goal_text}{closing_tag}' + ).format( + initial_tag=HTML( + '<div tabindex="0" aria-label="{aria_label_choice}" class="goal-option {col_sel} btn" ' + 'data-choice="{goal_key}">' + ).format( + goal_key=goal_key, + aria_label_choice=Text(_("Set goal to: {goal_text}")).format( + goal_text=Text(_(goal_text)) + ), + col_sel='col-' + str(int(math.floor(12 / len(goal_options)))) + ), + goal_text=goal_text, + closing_tag=HTML('</div>') ) - if user_access['is_enrolled'] and not course_start_data['already_started']: - CourseHomeMessages.register_info_message( - request, - Text(_( - "Don't forget to add a calendar reminder!" - )), - title=Text("Course starts in {days_until_start_string} on {course_start_date}.").format( - days_until_start_string=course_start_data['days_until_start_string'], - course_start_date=course_start_data['course_start_date'] - ) + + CourseHomeMessages.register_info_message( + request, + HTML('{goal_choices_html}{closing_tag}').format( + goal_choices_html=goal_choices_html, + closing_tag=HTML('</div>') + ), + title=Text(_('Welcome to {course_display_name}')).format( + course_display_name=course.display_name ) + ) diff --git a/webpack.config.js b/webpack.config.js index 4c5fc62047b5bf306d7f24b9cb2f530ff8d00d9d..6e56c1ef039d233c3ae83a133d5c920e7deb39cc 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -23,6 +23,7 @@ var wpconfig = { StudioIndex: './cms/static/js/features_jsx/studio/index.jsx', // Features + CourseGoals: './openedx/features/course_experience/static/course_experience/js/CourseGoals.js', CourseHome: './openedx/features/course_experience/static/course_experience/js/CourseHome.js', CourseOutline: './openedx/features/course_experience/static/course_experience/js/CourseOutline.js', CourseSock: './openedx/features/course_experience/static/course_experience/js/CourseSock.js',