Add message for setting course goal.
LEARNER-2307
This commit is contained in:
committed by
Robert Raposa
parent
a58df6c0fa
commit
bc76ffe5dc
@@ -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)
|
||||
|
||||
0
lms/djangoapps/course_goals/__init__.py
Normal file
0
lms/djangoapps/course_goals/__init__.py
Normal file
76
lms/djangoapps/course_goals/api.py
Normal file
76
lms/djangoapps/course_goals/api.py
Normal 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]
|
||||
29
lms/djangoapps/course_goals/migrations/0001_initial.py
Normal file
29
lms/djangoapps/course_goals/migrations/0001_initial.py
Normal 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')]),
|
||||
),
|
||||
]
|
||||
0
lms/djangoapps/course_goals/migrations/__init__.py
Normal file
0
lms/djangoapps/course_goals/migrations/__init__.py
Normal file
35
lms/djangoapps/course_goals/models.py
Normal file
35
lms/djangoapps/course_goals/models.py
Normal 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")
|
||||
19
lms/djangoapps/course_goals/signals.py
Normal file
19
lms/djangoapps/course_goals/signals.py
Normal 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,
|
||||
}
|
||||
)
|
||||
0
lms/djangoapps/course_goals/tests/__init__.py
Normal file
0
lms/djangoapps/course_goals/tests/__init__.py
Normal file
62
lms/djangoapps/course_goals/tests/test_api.py
Normal file
62
lms/djangoapps/course_goals/tests/test_api.py
Normal 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
|
||||
15
lms/djangoapps/course_goals/urls.py
Normal file
15
lms/djangoapps/course_goals/urls.py
Normal 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')),
|
||||
)
|
||||
92
lms/djangoapps/course_goals/views.py
Normal file
92
lms/djangoapps/course_goals/views.py
Normal 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,
|
||||
}
|
||||
)
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 += (
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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),
|
||||
|
||||
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),
|
||||
),
|
||||
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']
|
||||
)
|
||||
)
|
||||
|
||||
# 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)
|
||||
),
|
||||
),
|
||||
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))
|
||||
),
|
||||
register_link=HTML("<a href='/register?next={current_url}'>{register_label}</a>").format(
|
||||
register_label=_("register"),
|
||||
current_url=urlquote_plus(request.path),
|
||||
)
|
||||
col_sel='col-' + str(int(math.floor(12 / len(goal_options))))
|
||||
),
|
||||
title='You must be enrolled in the course to see course content.'
|
||||
goal_text=goal_text,
|
||||
closing_tag=HTML('</div>')
|
||||
)
|
||||
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']
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user