Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
E
edx-platform
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
edx
edx-platform
Commits
f534e018
Commit
f534e018
authored
Oct 06, 2017
by
Harry Rein
Committed by
GitHub
Oct 06, 2017
Browse files
Options
Browse Files
Download
Plain Diff
Merge pull request #16096 from edx/HarryRein/LEARNER-2308-update-course-goal
Harry rein/learner 2308 update course goal
parents
c7bfd69e
f3f3edf4
Hide whitespace changes
Inline
Side-by-side
Showing
17 changed files
with
446 additions
and
136 deletions
+446
-136
common/test/acceptance/pages/lms/course_home.py
+10
-1
common/test/acceptance/tests/lms/test_lms_course_home.py
+4
-2
lms/djangoapps/course_goals/api.py
+53
-9
lms/djangoapps/course_goals/models.py
+24
-5
lms/djangoapps/course_goals/signals.py
+0
-19
lms/djangoapps/course_goals/tests/test_api.py
+20
-5
lms/djangoapps/course_goals/views.py
+54
-25
lms/djangoapps/support/tests/test_views.py
+6
-0
lms/static/sass/features/_course-experience.scss
+72
-3
lms/static/sass/shared-v2/_variables.scss
+10
-1
openedx/features/course_experience/static/course_experience/js/CourseGoals.js
+15
-10
openedx/features/course_experience/static/course_experience/js/CourseHome.js
+66
-0
openedx/features/course_experience/templates/course_experience/course-home-fragment.html
+29
-1
openedx/features/course_experience/templates/course_experience/course-messages-fragment.html
+3
-3
openedx/features/course_experience/tests/views/test_course_home.py
+42
-2
openedx/features/course_experience/views/course_home.py
+16
-0
openedx/features/course_experience/views/course_home_messages.py
+22
-50
No files found.
common/test/acceptance/pages/lms/course_home.py
View file @
f534e018
...
...
@@ -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
()
...
...
common/test/acceptance/tests/lms/test_lms_course_home.py
View file @
f534e018
...
...
@@ -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
=
{
...
...
lms/djangoapps/course_goals/api.py
View file @
f534e018
"""
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,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
}
lms/djangoapps/course_goals/models.py
View file @
f534e018
...
...
@@ -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
)
lms/djangoapps/course_goals/signals.py
deleted
100644 → 0
View file @
c7bfd69e
"""
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
,
}
)
lms/djangoapps/course_goals/tests/test_api.py
View file @
f534e018
...
...
@@ -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
.
assert
Equal
(
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 a
n 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.
...
...
lms/djangoapps/course_goals/views.py
View file @
f534e018
...
...
@@ -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
):
...
...
lms/djangoapps/support/tests/test_views.py
View file @
f534e018
...
...
@@ -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. """
...
...
lms/static/sass/features/_course-experience.scss
View file @
f534e018
...
...
@@ -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
);
}
}
}
...
...
lms/static/sass/shared-v2/_variables.scss
View file @
f534e018
...
...
@@ -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
// ----------------------------
...
...
openedx/features/course_experience/static/course_experience/js/CourseGoals.js
View file @
f534e018
...
...
@@ -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'
).
key
up
((
e
)
=>
{
$
(
'.goal-option'
).
key
press
((
e
)
=>
{
if
(
e
.
which
===
13
)
{
$
(
e
.
target
).
trigger
(
'click'
);
$
(
e
.
target
).
click
(
);
}
});
}
...
...
openedx/features/course_experience/static/course_experience/js/CourseHome.js
View file @
f534e018
...
...
@@ -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
();
...
...
openedx/features/course_experience/templates/course_experience/course-home-fragment.html
View file @
f534e018
...
...
@@ -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>
...
...
openedx/features/course_experience/templates/course_experience/course-messages-fragment.html
View file @
f534e018
...
...
@@ -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
...
...
openedx/features/course_experience/tests/views/test_course_home.py
View file @
f534e018
...
...
@@ -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
(
4
5
,
table_blacklist
=
QUERY_COUNT_TABLE_BLACKLIST
):
with
self
.
assertNumQueries
(
4
9
,
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
...
...
openedx/features/course_experience/views/course_home.py
View file @
f534e018
...
...
@@ -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
,
...
...
openedx/features/course_experience/views/course_home_messages.py
View file @
f534e018
...
...
@@ -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_option
s
))))
col_sel
=
'col-'
+
str
(
int
(
math
.
floor
(
12
/
len
(
course_goal_key
s
))))
),
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
)
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment