Commit f3f3edf4 by Harry Rein

Allow user to update course goal on course home page.

LEARNER-2308

Once a goal has been set for a user on the course home message,
allow them to update it on the course home side bar. Automatically
sets a course goal for users when enrolling in a verifiable course.
parent 612c99ba
......@@ -34,13 +34,22 @@ class CourseHomePage(CoursePage):
def select_course_goal(self):
""" Click on a course goal in a message """
self.q(css='.goal-option').first.click()
self.q(css='button.goal-option').first.click()
self.wait_for_ajax()
def is_course_goal_success_message_shown(self):
""" Verifies course goal success message appears. """
return self.q(css='.success-message').present
def is_course_goal_update_field_shown(self):
""" Verifies course goal success message appears. """
return self.q(css='.current-goal-container').visible
def is_course_goal_update_icon_shown(self, valid=True):
""" Verifies course goal success or error icon appears. """
correct_icon = 'check' if valid else 'close'
return self.q(css='.fa-{icon}'.format(icon=correct_icon)).present
def click_bookmarks_button(self):
""" Click on Bookmarks button """
self.q(css='.bookmarks-list-button').first.click()
......
......@@ -63,7 +63,6 @@ class CourseHomeTest(CourseHomeBaseTest):
"""
Tests the course home page with course outline.
"""
def test_course_home(self):
"""
Smoke test of course goals, course outline, breadcrumbs to and from course outline, and bookmarks.
......@@ -81,11 +80,14 @@ class CourseHomeTest(CourseHomeBaseTest):
# Check that the tab lands on the course home page.
self.assertTrue(self.course_home_page.is_browser_on_page())
# Check that a success message is shown when selecting a course goal
# Check that a success message and update course field are shown when selecting a course goal
# TODO: LEARNER-2522: Ensure the correct message shows up for a particular goal choice
self.assertFalse(self.course_home_page.is_course_goal_success_message_shown())
self.assertFalse(self.course_home_page.is_course_goal_update_field_shown())
self.course_home_page.select_course_goal()
self.course_home_page.wait_for_ajax()
self.assertTrue(self.course_home_page.is_course_goal_success_message_shown())
self.assertTrue(self.course_home_page.is_course_goal_update_field_shown())
# Check that the course navigation appears correctly
EXPECTED_SECTIONS = {
......
"""
Course Goals Python API
"""
import models
from opaque_keys.edx.keys import CourseKey
from django.conf import settings
from rest_framework.reverse import reverse
from .models import CourseGoal
from course_modes.models import CourseMode
from openedx.features.course_experience import ENABLE_COURSE_GOALS
def add_course_goal(user, course_id, goal_key):
"""
Add a new course goal for the provided user and course.
Add a new course goal for the provided user and course. If the goal
already exists, simply update and save the goal.
Arguments:
user: The user that is setting the goal
......@@ -16,9 +22,15 @@ def add_course_goal(user, course_id, goal_key):
goal_key (string): The goal key for the new goal.
"""
# 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)
current_goal = get_course_goal(user, course_key)
if current_goal:
# If a course goal already exists, simply update it.
current_goal.goal_key = goal_key
current_goal.save(update_fields=['goal_key'])
else:
# Otherwise, create and save a new course goal.
new_goal = models.CourseGoal(user=user, course_key=course_key, goal_key=goal_key)
new_goal.save()
......@@ -26,16 +38,48 @@ 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.
If the user is anonymous or a course goal does not exist, returns None.
"""
course_goals = CourseGoal.objects.filter(user=user, course_key=course_key)
if user.is_anonymous():
return None
course_goals = models.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):
def remove_course_goal(user, course_id):
"""
Given a user and a course_key, remove the course goal.
Given a user and a course_id, remove the course goal.
"""
course_key = CourseKey.from_string(course_id)
course_goal = get_course_goal(user, course_key)
if course_goal:
course_goal.delete()
def get_goal_api_url(request):
"""
Returns the endpoint for accessing REST API.
"""
return reverse('course_goals_api:v0:course_goal-list', request=request)
def has_course_goal_permission(request, course_id, user_access):
"""
Returns whether the user can access the course goal functionality.
Only authenticated users that are enrolled in a verifiable course
can use this feature.
"""
course_key = CourseKey.from_string(course_id)
has_verified_mode = CourseMode.has_verified_mode(CourseMode.modes_for_course_dict(unicode(course_id)))
return user_access['is_enrolled'] and has_verified_mode and ENABLE_COURSE_GOALS.is_enabled(course_key) \
and settings.FEATURES.get('ENABLE_COURSE_GOALS')
def get_course_goal_options():
"""
Returns the valid options for goal keys, mapped to their translated
strings, as defined by theCourseGoal model.
"""
return {goal_key: goal_text for goal_key, goal_text in models.GOAL_KEY_CHOICES}
......@@ -3,23 +3,28 @@ Course Goals Models
"""
from django.contrib.auth.models import User
from django.db import models
from django.dispatch import receiver
from django.utils.translation import ugettext_lazy as _
from openedx.core.djangoapps.xmodule_django.models import CourseKeyField
from model_utils import Choices
from .api import add_course_goal, remove_course_goal
from course_modes.models import CourseMode
from student.models import CourseEnrollment
# Each goal is represented by a goal key and a string description.
GOAL_KEY_CHOICES = Choices(
('certify', _('Earn a certificate.')),
('complete', _('Complete the course.')),
('explore', _('Explore the course.')),
('unsure', _('Not sure yet.')),
('certify', _('Earn a certificate')),
('complete', _('Complete the course')),
('explore', _('Explore the course')),
('unsure', _('Not sure yet')),
)
class CourseGoal(models.Model):
"""
Represents a course goal set by the user.
Represents a course goal set by a user on the course home page.
"""
user = models.ForeignKey(User, blank=False)
course_key = CourseKeyField(max_length=255, db_index=True)
......@@ -34,3 +39,17 @@ class CourseGoal(models.Model):
class Meta:
unique_together = ("user", "course_key")
@receiver(models.signals.post_save, sender=CourseEnrollment, dispatch_uid="update_course_goal_on_enroll_change")
def update_course_goal_on_enroll_change(sender, instance, **kwargs): # pylint: disable=unused-argument, invalid-name
"""
Updates goals as follows on enrollment changes:
1) Set the course goal to 'certify' when the user enrolls as a verified user.
2) Remove the course goal when the user's enrollment is no longer active.
"""
course_id = str(instance.course_id).decode('utf8', 'ignore')
if not instance.is_active:
remove_course_goal(instance.user, course_id)
elif instance.mode == CourseMode.VERIFIED:
add_course_goal(instance.user, course_id, GOAL_KEY_CHOICES.certify)
"""
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,
}
)
......@@ -12,13 +12,14 @@ from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
TEST_PASSWORD = 'test'
EVENT_NAME_ADDED = 'edx.course.goal.added'
EVENT_NAME_UPDATED = 'edx.course.goal.updated'
class TestCourseGoalsAPI(EventTrackingTestCase, SharedModuleStoreTestCase):
"""
Testing the Course Goals API.
"""
def setUp(self):
# Create a course with a verified track
super(TestCourseGoalsAPI, self).setUp()
......@@ -35,17 +36,31 @@ class TestCourseGoalsAPI(EventTrackingTestCase, SharedModuleStoreTestCase):
def test_add_valid_goal(self):
""" Ensures a correctly formatted post succeeds. """
response = self.post_course_goal(valid=True)
self.assert_events_emitted()
response = self.post_course_goal(valid=True, goal_key='certify')
self.assertEqual(self.get_event(-1)['name'], EVENT_NAME_ADDED)
self.assertEqual(response.status_code, 201)
self.assertEqual(len(CourseGoal.objects.filter(user=self.user, course_key=self.course.id)), 1)
current_goals = CourseGoal.objects.filter(user=self.user, course_key=self.course.id)
self.assertEqual(len(current_goals), 1)
self.assertEqual(current_goals[0].goal_key, 'certify')
def test_add_invalid_goal(self):
""" Ensures a correctly formatted post succeeds. """
""" Ensures an incorrectly formatted post does not succeed. """
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 test_update_goal(self):
""" Ensures that repeated course goal post events do not create new instances of the goal. """
self.post_course_goal(valid=True, goal_key='explore')
self.post_course_goal(valid=True, goal_key='certify')
self.post_course_goal(valid=True, goal_key='unsure')
self.assertEqual(self.get_event(-1)['name'], EVENT_NAME_UPDATED)
current_goals = CourseGoal.objects.filter(user=self.user, course_key=self.course.id)
self.assertEqual(len(current_goals), 1)
self.assertEqual(current_goals[0].goal_key, 'unsure')
def post_course_goal(self, valid=True, goal_key='certify'):
"""
Sends a post request to set a course goal and returns the response.
......
......@@ -4,14 +4,17 @@ 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 django.http import JsonResponse
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 import permissions, serializers, viewsets, status
from rest_framework.authentication import SessionAuthentication
from rest_framework.response import Response
from .models import CourseGoal
from .api import get_course_goal_options
from .models import CourseGoal, GOAL_KEY_CHOICES
User = get_user_model()
......@@ -27,46 +30,72 @@ class CourseGoalSerializer(serializers.ModelSerializer):
model = CourseGoal
fields = ('user', 'course_key', 'goal_key')
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.
API calls to create and update a course goal.
Validates incoming data to ensure that course_key maps to an actual
course and that the goal_key is a valid option.
**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.
* Update an existing goal for a user
**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>"}
Returns Http400 response if the course_key does not map to a known
course or if the goal_key does not map to a valid goal key.
"""
authentication_classes = (JwtAuthentication, SessionAuthentication,)
permission_classes = (permissions.IsAuthenticated, IsStaffOrOwner,)
queryset = CourseGoal.objects.all()
serializer_class = CourseGoalSerializer
def create(self, post_data):
""" Create a new goal if one does not exist, otherwise update the existing goal. """
# Ensure goal_key is valid
goal_options = get_course_goal_options()
goal_key = post_data.data['goal_key']
if goal_key not in goal_options:
return Response(
'Provided goal key, {goal_key}, is not a valid goal key (options= {goal_options}).'.format(
goal_key=goal_key,
goal_options=goal_options,
),
status=status.HTTP_400_BAD_REQUEST,
)
# Ensure course key is valid
course_key = CourseKey.from_string(post_data.data['course_key'])
if not course_key:
return Response(
'Provided course_key ({course_key}) does not map to a course.'.format(
course_key=course_key
),
status=status.HTTP_400_BAD_REQUEST,
)
user = post_data.user
goal = CourseGoal.objects.filter(user=user.id, course_key=course_key).first()
if goal:
goal.goal_key = goal_key
goal.save(update_fields=['goal_key'])
else:
CourseGoal.objects.create(
user=user,
course_key=course_key,
goal_key=goal_key,
)
data = {
'goal_key': str(goal_key),
'goal_text': str(goal_options[goal_key]),
'is_unsure': goal_key == GOAL_KEY_CHOICES.unsure,
}
return JsonResponse(data, content_type="application/json", status=(200 if goal else 201))
@receiver(post_save, sender=CourseGoal, dispatch_uid="emit_course_goals_event")
def emit_course_goal_event(sender, instance, **kwargs):
......
......@@ -10,9 +10,11 @@ from datetime import datetime, timedelta
import ddt
from django.core.urlresolvers import reverse
from django.db.models import signals
from nose.plugins.attrib import attr
from pytz import UTC
from common.test.utils import disable_signal
from course_modes.models import CourseMode
from course_modes.tests.factories import CourseModeFactory
from lms.djangoapps.verify_student.models import VerificationDeadline
......@@ -223,6 +225,7 @@ class SupportViewEnrollmentsTests(SharedModuleStoreTestCase, SupportViewTestCase
'reason': 'Financial Assistance',
}, json.loads(response.content)[0]['manual_enrollment'])
@disable_signal(signals, 'post_save')
@ddt.data('username', 'email')
def test_change_enrollment(self, search_string_type):
self.assertIsNone(ManualEnrollmentAudit.get_manual_enrollment_by_email(self.student.email))
......@@ -274,12 +277,14 @@ class SupportViewEnrollmentsTests(SharedModuleStoreTestCase, SupportViewTestCase
self.assert_enrollment(CourseMode.AUDIT)
self.assertIsNone(ManualEnrollmentAudit.get_manual_enrollment_by_email(self.student.email))
@disable_signal(signals, 'post_save')
@ddt.data('honor', 'audit', 'verified', 'professional', 'no-id-professional')
def test_update_enrollment_for_all_modes(self, new_mode):
""" Verify support can changed the enrollment to all available modes
except credit. """
self.assert_update_enrollment('username', new_mode)
@disable_signal(signals, 'post_save')
@ddt.data('honor', 'audit', 'verified', 'professional', 'no-id-professional')
def test_update_enrollment_for_ended_course(self, new_mode):
""" Verify support can changed the enrollment of archived course. """
......@@ -301,6 +306,7 @@ class SupportViewEnrollmentsTests(SharedModuleStoreTestCase, SupportViewTestCase
response = self.client.get(url)
self._assert_generated_modes(response)
@disable_signal(signals, 'post_save')
@ddt.data('username', 'email')
def test_update_enrollments_with_expired_mode(self, search_string_type):
""" Verify that enrollment can be updated to verified mode. """
......
......@@ -81,7 +81,11 @@
color: $black-t2;
}
}
// Course Goal Styling
// Course Goal Message Styling
.success-message {
font-size: font-size(small);
}
.goal-options-container {
margin-top: $baseline;
text-align: center;
......@@ -155,9 +159,74 @@
@include margin-left(0);
@include padding-left($baseline);
.section-tools li:not(:first-child) {
// Course Goal Updates
.section-goals {
@include float(left);
border: 1px solid $lms-border-color;
padding: $baseline*0.75 $baseline*0.75 $baseline*0.25;
border-radius: 5px;
position: relative;
width: 100%;
cursor: pointer;
margin-bottom: $baseline/2;
&.hidden {
display: none;
}
.edit-goal-select {
display: none;
background-color: transparent;
}
.edit-icon {
@include right($baseline/4);
position: absolute;
top: $baseline*0.6;
cursor: pointer;
border: transparent;
background-color: transparent;
&:hover {
color: $lms-border-color;
}
}
.current-goal-container {
.title{
@include float(left);
@include margin-right($baseline/4);
}
.title-label {
display: none;
}
.goal {
@include float(left);
@include padding-left($baseline*0.4);
}
.response-icon {
@include margin-left($baseline/4);
@include right(-1*$baseline);
top: $baseline*0.75;
position: absolute;
&.fa-check {
color: $success-color;
}
&.fa-close {
color: $error-color;
}
}
}
.section-tools .course-tool:not(:first-child) {
margin-top: ($baseline / 5);
}
}
}
// Course outline
......
......@@ -27,7 +27,7 @@ $lms-label-color: palette(grayscale, black) !default;
$lms-active-color: palette(primary, base) !default;
$lms-preview-menu-color: #c8c8c8 !default;
$lms-inactive-color: rgb(94,94,94) !default;
$success-color: palette(success, accent) !default;
$success-color: rgb(0, 155, 0) !default;
$success-color-hover: palette(success, text) !default;
$button-bg-hover-color: $white !default;
......@@ -49,6 +49,8 @@ $light-grey-solid: rgba(200,200,200, 1) !default;
$header-border-color: $gray-l1 !default;
$table-bg-accent: #f9f9f9 !default;
// ----------------------------
// #TYPOGRAPHY
// ----------------------------
......@@ -68,6 +70,13 @@ $site-status-color: rgb(182,37,103) !default;
$shadow-l1: rgba(0,0,0,0.1) !default;
$error-color: rgb(203, 7, 18) !default;
$warning-color: rgb(255, 192, 31) !default;
$confirm-color: rgb(0, 132, 1) !default;
$active-color: $blue !default;
$highlight-color: rgb(255,255,0) !default;
$alert-color: rgb(212, 64, 64) !default;
// ----------------------------
// #ALERTS
// ----------------------------
......
......@@ -15,15 +15,20 @@ export class CourseGoals { // eslint-disable-line import/prefer-default-export
user: options.username,
},
dataType: 'json',
success: () => {
// LEARNER-2522 will address the success message
const successMsg = gettext('Thank you for setting your course goal!');
success: (data) => { // LEARNER-2522 will address the success message
$('.section-goals').slideDown();
$('.section-goals .goal .text').text(data.goal_text);
$('.section-goals select').val(data.goal_key);
const successMsg = gettext(`Thank you for setting your course goal to ${data.goal_text.toLowerCase()}!`);
if (!data.is_unsure) {
// xss-lint: disable=javascript-jquery-html
$('.message-content').html(`<div class="success-message">${successMsg}</div>`);
} else {
$('.message-content').parent().hide();
}
},
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
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.');
// xss-lint: disable=javascript-jquery-html
$('.message-content').html(`<div class="error-message"> ${errorMsg} </div>`);
},
......@@ -31,9 +36,9 @@ export class CourseGoals { // eslint-disable-line import/prefer-default-export
});
// Allow goal selection with an enter press for accessibility purposes
$('.goal-option').keyup((e) => {
$('.goal-option').keypress((e) => {
if (e.which === 13) {
$(e.target).trigger('click');
$(e.target).click();
}
});
}
......
......@@ -30,6 +30,72 @@ export class CourseHome { // eslint-disable-line import/prefer-default-export
);
});
// Course goal editing elements
const $goalSection = $('.section-goals');
const $editGoalIcon = $('.section-goals .edit-icon');
const $currentGoalText = $('.section-goals .goal');
const $goalSelect = $('.section-goals .edit-goal-select');
const $responseIndicator = $('.section-goals .response-icon');
const $responseMessageSr = $('.section-goals .sr-update-response-msg');
const $goalUpdateTitle = $('.section-goals .title:not("label")');
const $goalUpdateLabel = $('.section-goals label.title');
// Switch to editing mode when the goal section is clicked
$goalSection.on('click', (event) => {
if (!$(event.target).hasClass('edit-goal-select')) {
$goalSelect.toggle();
$currentGoalText.toggle();
$goalUpdateTitle.toggle();
$goalUpdateLabel.toggle();
$responseIndicator.removeClass().addClass('response-icon');
$goalSelect.focus();
}
});
// Trigger click event on enter press for accessibility purposes
$(document.body).on('keyup', '.section-goals .edit-icon', (event) => {
if (event.which === 13) {
$(event.target).trigger('click');
}
});
// Send an ajax request to update the course goal
$goalSelect.on('change', (event) => {
const newGoalKey = $(event.target).val();
$goalSelect.toggle();
$currentGoalText.toggle();
$goalUpdateTitle.toggle();
$goalUpdateLabel.toggle();
$responseIndicator.removeClass().addClass('response-icon fa fa-spinner fa-spin');
$.ajax({
method: 'POST',
url: options.goalApiUrl,
headers: { 'X-CSRFToken': $.cookie('csrftoken') },
data: {
goal_key: newGoalKey,
course_key: options.courseId,
user: options.username,
},
dataType: 'json',
success: (data) => {
$currentGoalText.find('.text').text(data.goal_text);
$responseMessageSr.text(gettext('You have successfully updated your goal.'));
$responseIndicator.removeClass().addClass('response-icon fa fa-check');
},
error: () => {
$responseIndicator.removeClass().addClass('response-icon fa fa-close');
$responseMessageSr.text(gettext('There was an error updating your goal.'));
},
complete: () => {
// Only show response icon indicator for 3 seconds.
setTimeout(() => {
$responseIndicator.removeClass().addClass('response-icon');
}, 3000);
$editGoalIcon.focus();
},
});
});
// Dismissibility for in course messages
$(document.body).on('click', '.course-message .dismiss', (event) => {
$(event.target).closest('.course-message').hide();
......
......@@ -106,12 +106,37 @@ from openedx.features.course_experience import UNIFIED_COURSE_TAB_FLAG, SHOW_REV
% endif
</main>
<aside class="course-sidebar layout-col layout-col-a">
% if has_goal_permission:
<div class="section section-goals ${'' if current_goal else 'hidden'}">
<div class="current-goal-container">
<label class="title title-label hd-6" for="goal">
<h3 class="hd-6">${_("Goal: ")}</h3>
</label>
<h3 class="title hd-6">${_("Goal: ")}</h3>
<div class="goal">
<span class="text">${goal_options[current_goal.goal_key] if current_goal else ""}</span>
</div>
<select class="edit-goal-select" id="goal">
% for goal, goal_text in goal_options.items():
<option value="${goal}" ${"selected" if current_goal and current_goal.goal_key == goal else ""}>${goal_text}</option>
% endfor
</select>
<span class="sr sr-update-response-msg" aria-live="polite"></span>
<span class="response-icon" aria-hidden="true"></span>
<span class="sr">${_("Edit your course goal:")}</span>
<button class="edit-icon">
<span class="sr">${_("Edit your course goal:")}</span>
<span class="fa fa-pencil" aria-hidden="true"></span>
</button>
</div>
</div>
% endif
% if course_tools:
<div class="section section-tools">
<h3 class="hd-6">${_("Course Tools")}</h3>
<ul class="list-unstyled">
% for course_tool in course_tools:
<li>
<li class="course-tool">
<a class="course-tool-link" data-analytics-id="${course_tool.analytics_id()}" href="${course_tool.url(course_key)}">
<span class="icon ${course_tool.icon_classes()}" aria-hidden="true"></span>
${course_tool.title()}
......@@ -146,6 +171,9 @@ from openedx.features.course_experience import UNIFIED_COURSE_TAB_FLAG, SHOW_REV
courseRunKey: "${course_key | n, js_escaped_string}",
resumeCourseLink: ".action-resume-course",
courseToolLink: ".course-tool-link",
goalApiUrl: "${goal_api_url | n, js_escaped_string}",
username: "${username | n, js_escaped_string}",
courseId: "${course.id | n, js_escaped_string}",
});
</%static:webpack>
......
......@@ -19,13 +19,13 @@ 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" alt="${_('Course message author')}" role="none" src="${static.url(image_src)}"/>
<img class="message-author" alt="" src="${static.url(image_src)}"/>
% endif
<div class="message-content">
<div class="message-content" aria-live="polite">
${HTML(message.message_html)}
</div>
% if is_rtl:
<img class="message-author" alt="${_('Course message author')}" role="none" src="${static.url(image_src)}"/>
<img class="message-author" alt="" src="${static.url(image_src)}"/>
% endif
</div>
% endfor
......
......@@ -45,6 +45,8 @@ 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'
TEST_COURSE_GOAL_UPDATE_FIELD = 'section-goals'
TEST_COURSE_GOAL_UPDATE_FIELD_HIDDEN = 'section-goals hidden'
COURSE_GOAL_DISMISS_OPTION = 'unsure'
QUERY_COUNT_TABLE_BLACKLIST = WAFFLE_TABLES
......@@ -173,7 +175,7 @@ class TestCourseHomePage(CourseHomePageTestCase):
course_home_url(self.course)
# Fetch the view and verify the query counts
with self.assertNumQueries(45, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST):
with self.assertNumQueries(49, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST):
with check_mongo_calls(4):
url = course_home_url(self.course)
self.client.get(url)
......@@ -427,7 +429,7 @@ class TestCourseHomePageAccess(CourseHomePageTestCase):
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)
remove_course_goal(user, str(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)
......@@ -438,6 +440,44 @@ class TestCourseHomePageAccess(CourseHomePageTestCase):
response = self.client.get(course_home_url(audit_only_course))
self.assertNotContains(response, TEST_COURSE_GOAL_OPTIONS)
@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_goal_updates(self):
"""
Ensure that the following five use cases work as expected.
1) Unenrolled users are not shown the update goal selection field.
2) Enrolled users are not shown the update goal selection field if they have not yet set a course goal.
3) Enrolled users are shown the update goal selection field if they have set a course goal.
4) Enrolled users in the verified track are shown the update goal selection field.
"""
# 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 update goal selection field.
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_UPDATE_FIELD)
# Verify that enrolled users that have not set a course goal are shown a hidden update goal selection field.
enrollment = CourseEnrollment.enroll(user, verifiable_course.id)
response = self.client.get(course_home_url(verifiable_course))
self.assertContains(response, TEST_COURSE_GOAL_UPDATE_FIELD_HIDDEN)
# Verify that enrolled users that have set a course goal are shown a visible update goal selection field.
add_course_goal(user, verifiable_course.id, COURSE_GOAL_DISMISS_OPTION)
response = self.client.get(course_home_url(verifiable_course))
self.assertContains(response, TEST_COURSE_GOAL_UPDATE_FIELD)
self.assertNotContains(response, TEST_COURSE_GOAL_UPDATE_FIELD_HIDDEN)
# Verify that enrolled and verified users are shown the update goal selection
CourseEnrollment.update_enrollment(enrollment, is_active=True, mode=CourseMode.VERIFIED)
response = self.client.get(course_home_url(verifiable_course))
self.assertContains(response, TEST_COURSE_GOAL_UPDATE_FIELD)
self.assertNotContains(response, TEST_COURSE_GOAL_UPDATE_FIELD_HIDDEN)
class CourseHomeFragmentViewTests(ModuleStoreTestCase):
CREATE_USER = False
......
......@@ -17,6 +17,7 @@ from courseware.courses import (
get_course_info_section,
get_course_with_access,
)
from lms.djangoapps.course_goals.api import get_course_goal, has_course_goal_permission, get_course_goal_options, get_goal_api_url
from lms.djangoapps.courseware.exceptions import CourseAccessRedirect
from lms.djangoapps.courseware.views.views import CourseTabView
from opaque_keys.edx.keys import CourseKey
......@@ -155,6 +156,16 @@ class CourseHomeFragmentView(EdxFragmentView):
# Get the course tools enabled for this user and course
course_tools = CourseToolsPluginManager.get_enabled_course_tools(request, course_key)
# Check if the user can access the course goal functionality
has_goal_permission = has_course_goal_permission(request, course_id, user_access)
# Grab the current course goal and the acceptable course goal keys mapped to translated values
current_goal = get_course_goal(request.user, course_key)
goal_options = get_course_goal_options()
# Get the course goals api endpoint
goal_api_url = get_goal_api_url(request)
# Grab the course home messages fragment to render any relevant django messages
course_home_message_fragment = CourseHomeMessageFragmentView().render_to_fragment(
request, course_id=course_id, user_access=user_access, **kwargs
......@@ -182,6 +193,11 @@ class CourseHomeFragmentView(EdxFragmentView):
'resume_course_url': resume_course_url,
'course_tools': course_tools,
'dates_fragment': dates_fragment,
'username': request.user.username,
'goal_api_url': goal_api_url,
'has_goal_permission': has_goal_permission,
'goal_options': goal_options,
'current_goal': current_goal,
'update_message_fragment': update_message_fragment,
'course_sock_fragment': course_sock_fragment,
'disable_courseware_js': True,
......
......@@ -5,7 +5,6 @@ import math
from datetime import datetime
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
......@@ -13,20 +12,16 @@ from django.utils.timezone import UTC
from django.utils.translation import ugettext as _
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_date_blocks, get_course_with_access
from lms.djangoapps.course_goals.api import get_course_goal
from lms.djangoapps.course_goals.api import get_course_goal, get_course_goal_options, get_goal_api_url, has_course_goal_permission
from lms.djangoapps.course_goals.models import GOAL_KEY_CHOICES
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):
"""
......@@ -71,14 +66,19 @@ class CourseHomeMessageFragmentView(EdxFragmentView):
course_date_block.register_alerts(request, course)
# Register a course goal message, if appropriate
if _should_show_course_goal_message(request, course, user_access):
# 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.
user_goal = get_course_goal(auth.get_user(request), course_key)
is_already_verified = CourseEnrollment.is_enrolled_as_verified(request.user, course_key)
if has_course_goal_permission(request, course_id, user_access) and not is_already_verified and not user_goal:
_register_course_goal_message(request, course)
# Grab the relevant messages
course_home_messages = list(CourseHomeMessages.user_messages(request))
# Pass in the url used to set a course goal
goal_api_url = reverse('course_goals_api:v0:course_goal-list', request=request)
goal_api_url = get_goal_api_url(request)
# Grab the logo
image_src = 'course_experience/images/home_message_author.png'
......@@ -131,39 +131,11 @@ def _register_course_home_messages(request, course, user_access, course_start_da
)
def _should_show_course_goal_message(request, course, user_access):
"""
Returns true if the current learner should be shown a course goal message.
"""
course_key = course.id
# Don't show a message if course goals has not been enabled
if not ENABLE_COURSE_GOALS.is_enabled(course_key) or not settings.FEATURES.get('ENABLE_COURSE_GOALS'):
return False
# Don't show a message if the user is not enrolled
if not user_access['is_enrolled']:
return False
# Don't show a message if the learner has already specified a goal
if get_course_goal(auth.get_user(request), course_key):
return False
# Don't show a message if the course does not have a verified mode
if not CourseMode.has_verified_mode(CourseMode.modes_for_course_dict(unicode(course_key))):
return False
# Don't show a message if the learner has already verified
if CourseEnrollment.is_enrolled_as_verified(request.user, course_key):
return False
return True
def _register_course_goal_message(request, course):
"""
Register a message to let a learner specify a course goal.
"""
course_goal_options = get_course_goal_options()
goal_choices_html = Text(_(
'To start, set a course goal by selecting the option below that best describes '
'your learning plan. {goal_options_container}'
......@@ -181,44 +153,44 @@ def _register_course_goal_message(request, course):
).format(
goal_key=GOAL_KEY_CHOICES.unsure,
aria_label_choice=Text(_("Set goal to: {choice}")).format(
choice=GOAL_KEY_CHOICES[GOAL_KEY_CHOICES.unsure]
choice=course_goal_options[GOAL_KEY_CHOICES.unsure],
),
),
choice=Text(_('{choice}')).format(
choice=GOAL_KEY_CHOICES[GOAL_KEY_CHOICES.unsure],
choice=course_goal_options[GOAL_KEY_CHOICES.unsure],
),
closing_tag=HTML('</div>'),
)
# Add the option to set a goal to earn a certificate,
# complete the course or explore the course
goal_options = [
GOAL_KEY_CHOICES.certify,
GOAL_KEY_CHOICES.complete,
GOAL_KEY_CHOICES.explore
]
for goal_key in goal_options:
goal_text = GOAL_KEY_CHOICES[goal_key]
course_goal_keys = course_goal_options.keys()
course_goal_keys.remove(GOAL_KEY_CHOICES.unsure)
for goal_key in course_goal_keys:
goal_text = course_goal_options[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" '
'<button 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))))
col_sel='col-' + str(int(math.floor(12 / len(course_goal_keys))))
),
goal_text=goal_text,
closing_tag=HTML('</div>')
closing_tag=HTML('</button>')
)
CourseHomeMessages.register_info_message(
request,
goal_choices_html,
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
)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment