Add message for setting course goal.

LEARNER-2307
This commit is contained in:
Harry Rein
2017-09-11 10:43:24 -04:00
committed by Robert Raposa
parent a58df6c0fa
commit bc76ffe5dc
25 changed files with 644 additions and 73 deletions

View File

View File

@@ -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]

View File

@@ -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')]),
),
]

View File

@@ -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")

View File

@@ -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,
}
)

View File

@@ -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

View File

@@ -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')),
)

View File

@@ -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,
}
)

View File

@@ -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',

View File

@@ -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;
}
}
}
}
}
}

View File

@@ -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">

View File

@@ -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 += (