Skip to content

Commit

Permalink
Merge pull request openedx#16008 from edx/HarryRein/LEARNER-2307-cour…
Browse files Browse the repository at this point in the history
…se-goal-messaging

LEARNER-2307: Course Goal Messaging
  • Loading branch information
HarryRein authored Sep 22, 2017
2 parents bdb078d + bc76ffe commit 990a8cb
Show file tree
Hide file tree
Showing 25 changed files with 643 additions and 72 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ def test_save_subs_to_store(self):

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)
Expand Down
Empty file.
76 changes: 76 additions & 0 deletions lms/djangoapps/course_goals/api.py
Original file line number Diff line number Diff line change
@@ -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]
29 changes: 29 additions & 0 deletions lms/djangoapps/course_goals/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -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')]),
),
]
Empty file.
35 changes: 35 additions & 0 deletions lms/djangoapps/course_goals/models.py
Original file line number Diff line number Diff line change
@@ -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")
19 changes: 19 additions & 0 deletions lms/djangoapps/course_goals/signals.py
Original file line number Diff line number Diff line change
@@ -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,
}
)
Empty file.
62 changes: 62 additions & 0 deletions lms/djangoapps/course_goals/tests/test_api.py
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions lms/djangoapps/course_goals/urls.py
Original file line number Diff line number Diff line change
@@ -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')),
)
92 changes: 92 additions & 0 deletions lms/djangoapps/course_goals/views.py
Original file line number Diff line number Diff line change
@@ -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,
}
)
6 changes: 6 additions & 0 deletions lms/envs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,9 @@

# 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
Expand Down Expand Up @@ -2245,6 +2248,9 @@
'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',
Expand Down
Loading

0 comments on commit 990a8cb

Please sign in to comment.