From bc76ffe5dce593d2d3f6d2391db34bb2b4119801 Mon Sep 17 00:00:00 2001 From: Harry Rein Date: Mon, 11 Sep 2017 10:43:24 -0400 Subject: [PATCH] Add message for setting course goal. LEARNER-2307 --- .../tests/test_transcripts_utils.py | 2 +- lms/djangoapps/course_goals/__init__.py | 0 lms/djangoapps/course_goals/api.py | 76 ++++++++ .../course_goals/migrations/0001_initial.py | 29 +++ .../course_goals/migrations/__init__.py | 0 lms/djangoapps/course_goals/models.py | 35 ++++ lms/djangoapps/course_goals/signals.py | 19 ++ lms/djangoapps/course_goals/tests/__init__.py | 0 lms/djangoapps/course_goals/tests/test_api.py | 62 ++++++ lms/djangoapps/course_goals/urls.py | 15 ++ lms/djangoapps/course_goals/views.py | 92 +++++++++ lms/envs/common.py | 6 + .../sass/features/_course-experience.scss | 45 ++++- lms/templates/navigation/navigation.html | 2 +- lms/urls.py | 5 + openedx/core/lib/api/permissions.py | 1 + .../features/course_experience/__init__.py | 9 +- .../course_experience/js/CourseGoals.js | 40 ++++ .../static/course_experience/js/CourseHome.js | 12 ++ .../static/course_experience/js/CourseSock.js | 2 +- .../course-messages-fragment.html | 16 +- .../tests/views/test_course_home.py | 59 +++++- .../tests/views/test_course_sock.py | 8 +- .../views/course_home_messages.py | 181 +++++++++++++----- webpack.config.js | 1 + 25 files changed, 644 insertions(+), 73 deletions(-) create mode 100644 lms/djangoapps/course_goals/__init__.py create mode 100644 lms/djangoapps/course_goals/api.py create mode 100644 lms/djangoapps/course_goals/migrations/0001_initial.py create mode 100644 lms/djangoapps/course_goals/migrations/__init__.py create mode 100644 lms/djangoapps/course_goals/models.py create mode 100644 lms/djangoapps/course_goals/signals.py create mode 100644 lms/djangoapps/course_goals/tests/__init__.py create mode 100644 lms/djangoapps/course_goals/tests/test_api.py create mode 100644 lms/djangoapps/course_goals/urls.py create mode 100644 lms/djangoapps/course_goals/views.py create mode 100644 openedx/features/course_experience/static/course_experience/js/CourseGoals.js diff --git a/cms/djangoapps/contentstore/tests/test_transcripts_utils.py b/cms/djangoapps/contentstore/tests/test_transcripts_utils.py index e92e1f937a..4e9de8075f 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 0000000000..e69de29bb2 diff --git a/lms/djangoapps/course_goals/api.py b/lms/djangoapps/course_goals/api.py new file mode 100644 index 0000000000..b52c1794fa --- /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 0000000000..bcf13e339e --- /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 0000000000..e69de29bb2 diff --git a/lms/djangoapps/course_goals/models.py b/lms/djangoapps/course_goals/models.py new file mode 100644 index 0000000000..fac52a729a --- /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 0000000000..2957c2eb27 --- /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 0000000000..e69de29bb2 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 0000000000..1c20dd7ba3 --- /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 0000000000..cb87b3db77 --- /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 0000000000..513d52c440 --- /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": , "goal_key": "", "user": ""} + + """ + 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 beb140bea5..b9863b88c9 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 48060b0db9..fff4702801 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 2f1972b527..26d43a9e5f 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) % if uses_bootstrap: -