Merge pull request #16096 from edx/HarryRein/LEARNER-2308-update-course-goal
Harry rein/learner 2308 update course goal
This commit is contained in:
@@ -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 = {
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
"""
|
||||
Course Goals Python API
|
||||
"""
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
import models
|
||||
|
||||
from .models import CourseGoal
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from django.conf import settings
|
||||
from rest_framework.reverse import reverse
|
||||
|
||||
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,26 +22,64 @@ 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)
|
||||
new_goal.save()
|
||||
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()
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
"""
|
||||
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,8 +159,73 @@
|
||||
@include margin-left(0);
|
||||
@include padding-left($baseline);
|
||||
|
||||
.section-tools li:not(:first-child) {
|
||||
margin-top: ($baseline / 5);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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!');
|
||||
// xss-lint: disable=javascript-jquery-html
|
||||
$('.message-content').html(`<div class="success-message">${successMsg}</div>`);
|
||||
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
|
||||
@@ -14,20 +13,16 @@ from django.utils.translation import get_language, to_locale
|
||||
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):
|
||||
"""
|
||||
@@ -72,14 +67,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'
|
||||
@@ -132,39 +132,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}'
|
||||
@@ -182,44 +154,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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user