Commit 0dad9f61 by Calen Pennington Committed by GitHub

Merge pull request #14320 from edx/release-candidate

Merge release candidate to release
parents 6e043d97 28e3b1cb
...@@ -9,7 +9,6 @@ from mock import patch, Mock ...@@ -9,7 +9,6 @@ from mock import patch, Mock
from django.test.utils import override_settings from django.test.utils import override_settings
from django.conf import settings from django.conf import settings
from django.utils import translation from django.utils import translation
from django.utils.crypto import get_random_string
from nose.plugins.skip import SkipTest from nose.plugins.skip import SkipTest
...@@ -231,17 +230,6 @@ class TestDownloadYoutubeSubs(SharedModuleStoreTestCase): ...@@ -231,17 +230,6 @@ class TestDownloadYoutubeSubs(SharedModuleStoreTestCase):
self.assertEqual(html5_ids[2], 'baz.1.4') self.assertEqual(html5_ids[2], 'baz.1.4')
self.assertEqual(html5_ids[3], 'foo') self.assertEqual(html5_ids[3], 'foo')
def test_html5_id_length(self):
"""
Test that html5_id is parsed with length less than 255, as html5 ids are
used as name for transcript objects and ultimately as filename while creating
file for transcript at the time of exporting a course.
Filename can't be longer than 255 characters.
150 chars is agreed length.
"""
html5_ids = transcripts_utils.get_html5_ids([get_random_string(255)])
self.assertEqual(len(html5_ids[0]), 150)
@patch('xmodule.video_module.transcripts_utils.requests.get') @patch('xmodule.video_module.transcripts_utils.requests.get')
def test_fail_downloading_subs(self, mock_get): def test_fail_downloading_subs(self, mock_get):
......
...@@ -215,42 +215,6 @@ function($, _, Utils, _str) { ...@@ -215,42 +215,6 @@ function($, _, Utils, _str) {
}); });
}); });
}); });
describe('Too long arguments ', function() {
var longFileName = (function() {
var text = '';
var possibleChars = 'abcdefghijklmnopqrstuvwxyz';
/* eslint vars-on-top: 0 */
for (var i = 0; i < 255; i++) {
text += possibleChars.charAt(Math.floor(Math.random() * possibleChars.length));
}
return text;
}()),
html5LongUrls = (function(videoName) {
var links = [
'http://somelink.com/%s?param=1&param=2#hash',
'http://somelink.com/%s#hash',
'http://somelink.com/%s?param=1&param=2',
'http://somelink.com/%s',
'ftp://somelink.com/%s',
'https://somelink.com/%s',
'https://somelink.com/sub/sub/%s',
'http://cdn.somecdn.net/v/%s',
'somelink.com/%s',
'%s'
];
return $.map(links, function(link) {
return _str.sprintf(link, videoName);
});
}(longFileName));
$.each(html5LongUrls, function(index, link) {
it(link, function() {
var result = Utils.parseHTML5Link(link);
expect(result.video.length).toBe(150);
});
});
});
}); });
it('Method: getYoutubeLink', function() { it('Method: getYoutubeLink', function() {
......
...@@ -110,7 +110,6 @@ define(['jquery', 'underscore', 'jquery.ajaxQueue'], function($) { ...@@ -110,7 +110,6 @@ define(['jquery', 'underscore', 'jquery.ajaxQueue'], function($) {
*/ */
var _videoLinkParser = (function() { var _videoLinkParser = (function() {
var cache = {}; var cache = {};
var maxVideoNameLength = 150;
return function(url) { return function(url) {
if (typeof url !== 'string') { if (typeof url !== 'string') {
...@@ -130,10 +129,7 @@ define(['jquery', 'underscore', 'jquery.ajaxQueue'], function($) { ...@@ -130,10 +129,7 @@ define(['jquery', 'underscore', 'jquery.ajaxQueue'], function($) {
match = link.pathname.match(/\/{1}([^\/]+)\.([^\/]+)$/); match = link.pathname.match(/\/{1}([^\/]+)\.([^\/]+)$/);
if (match) { if (match) {
cache[url] = { cache[url] = {
/* avoid too long video name, as it will be used as filename for video's transcript video: match[1],
and a filename can not be more that 255 chars, limiting here to 150.
*/
video: match[1].slice(0, maxVideoNameLength),
type: match[2] type: match[2]
}; };
} else { } else {
...@@ -143,7 +139,7 @@ define(['jquery', 'underscore', 'jquery.ajaxQueue'], function($) { ...@@ -143,7 +139,7 @@ define(['jquery', 'underscore', 'jquery.ajaxQueue'], function($) {
match = link.pathname.match(/\/{1}([^\/\.]+)$/); match = link.pathname.match(/\/{1}([^\/\.]+)$/);
if (match) { if (match) {
cache[url] = { cache[url] = {
video: match[1].slice(0, maxVideoNameLength), video: match[1],
type: 'other' type: 'other'
}; };
} }
......
...@@ -144,7 +144,7 @@ from openedx.core.djangolib.markup import HTML, Text ...@@ -144,7 +144,7 @@ from openedx.core.djangolib.markup import HTML, Text
<h5 class="title">${_("Location ID")}</h5> <h5 class="title">${_("Location ID")}</h5>
<p class="unit-id"> <p class="unit-id">
<span class="unit-id-value" id="unit-location-id-input">${unit.location.name}</span> <span class="unit-id-value" id="unit-location-id-input">${unit.location.name}</span>
<span class="tip"><span class="sr">Tip: </span>${_("Use this ID when you create links to this unit from other course content. You enter the ID in the URL field.")}</span> <span class="tip"><span class="sr">Tip: </span>${_('To create a link to this unit from an HTML component in this course, enter "/jump_to_id/<location ID>" as the URL value.')}</span>
</p> </p>
</div> </div>
<div class="wrapper-unit-tree-location bar-mod-content"> <div class="wrapper-unit-tree-location bar-mod-content">
......
...@@ -25,6 +25,7 @@ from edxmako.shortcuts import render_to_response, render_to_string ...@@ -25,6 +25,7 @@ from edxmako.shortcuts import render_to_response, render_to_string
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
import track.views import track.views
from student.roles import GlobalStaff from student.roles import GlobalStaff
from student.models import CourseEnrollment
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -222,6 +223,40 @@ class _ZendeskApi(object): ...@@ -222,6 +223,40 @@ class _ZendeskApi(object):
return None return None
def _get_zendesk_custom_field_context(request):
"""
Construct a dictionary of data that can be stored in Zendesk custom fields.
"""
context = {}
course_id = request.POST.get("course_id")
if not course_id:
return context
context["course_id"] = course_id
if not request.user.is_authenticated():
return context
enrollment = CourseEnrollment.get_enrollment(request.user, CourseKey.from_string(course_id))
if enrollment and enrollment.is_active:
context["enrollment_mode"] = enrollment.mode
return context
def _format_zendesk_custom_fields(context):
"""
Format the data in `context` for compatibility with the Zendesk API.
Ignore any keys that have not been configured in `ZENDESK_CUSTOM_FIELDS`.
"""
custom_fields = []
for key, val, in settings.ZENDESK_CUSTOM_FIELDS.items():
if key in context:
custom_fields.append({"id": val, "value": context[key]})
return custom_fields
def _record_feedback_in_zendesk( def _record_feedback_in_zendesk(
realname, realname,
email, email,
...@@ -231,7 +266,8 @@ def _record_feedback_in_zendesk( ...@@ -231,7 +266,8 @@ def _record_feedback_in_zendesk(
additional_info, additional_info,
group_name=None, group_name=None,
require_update=False, require_update=False,
support_email=None support_email=None,
custom_fields=None
): ):
""" """
Create a new user-requested Zendesk ticket. Create a new user-requested Zendesk ticket.
...@@ -246,6 +282,8 @@ def _record_feedback_in_zendesk( ...@@ -246,6 +282,8 @@ def _record_feedback_in_zendesk(
If `require_update` is provided, returns False when the update does not If `require_update` is provided, returns False when the update does not
succeed. This allows using the private comment to add necessary information succeed. This allows using the private comment to add necessary information
which the user will not see in followup emails from support. which the user will not see in followup emails from support.
If `custom_fields` is provided, submits data to those fields in Zendesk.
""" """
zendesk_api = _ZendeskApi() zendesk_api = _ZendeskApi()
...@@ -271,6 +309,10 @@ def _record_feedback_in_zendesk( ...@@ -271,6 +309,10 @@ def _record_feedback_in_zendesk(
"tags": zendesk_tags "tags": zendesk_tags
} }
} }
if custom_fields:
new_ticket["ticket"]["custom_fields"] = custom_fields
group = None group = None
if group_name is not None: if group_name is not None:
group = zendesk_api.get_group(group_name) group = zendesk_api.get_group(group_name)
...@@ -322,7 +364,7 @@ def get_feedback_form_context(request): ...@@ -322,7 +364,7 @@ def get_feedback_form_context(request):
context["subject"] = request.POST["subject"] context["subject"] = request.POST["subject"]
context["details"] = request.POST["details"] context["details"] = request.POST["details"]
context["tags"] = dict( context["tags"] = dict(
[(tag, request.POST[tag]) for tag in ["issue_type", "course_id"] if tag in request.POST] [(tag, request.POST[tag]) for tag in ["issue_type", "course_id"] if request.POST.get(tag)]
) )
context["additional_info"] = {} context["additional_info"] = {}
...@@ -412,6 +454,11 @@ def submit_feedback(request): ...@@ -412,6 +454,11 @@ def submit_feedback(request):
if not settings.ZENDESK_URL or not settings.ZENDESK_USER or not settings.ZENDESK_API_KEY: if not settings.ZENDESK_URL or not settings.ZENDESK_USER or not settings.ZENDESK_API_KEY:
raise Exception("Zendesk enabled but not configured") raise Exception("Zendesk enabled but not configured")
custom_fields = None
if settings.ZENDESK_CUSTOM_FIELDS:
custom_field_context = _get_zendesk_custom_field_context(request)
custom_fields = _format_zendesk_custom_fields(custom_field_context)
success = _record_feedback_in_zendesk( success = _record_feedback_in_zendesk(
context["realname"], context["realname"],
context["email"], context["email"],
...@@ -419,7 +466,8 @@ def submit_feedback(request): ...@@ -419,7 +466,8 @@ def submit_feedback(request):
context["details"], context["details"],
context["tags"], context["tags"],
context["additional_info"], context["additional_info"],
support_email=context["support_email"] support_email=context["support_email"],
custom_fields=custom_fields
) )
_record_feedback_in_datadog(context["tags"]) _record_feedback_in_datadog(context["tags"])
......
...@@ -328,9 +328,16 @@ class InputTypeBase(object): ...@@ -328,9 +328,16 @@ class InputTypeBase(object):
} }
# Generate the list of ids to be used with the aria-describedby field. # Generate the list of ids to be used with the aria-describedby field.
descriptions = list()
# If there is trailing text, add the id as the first element to the list before adding the status id
if 'trailing_text' in self.loaded_attributes and self.loaded_attributes['trailing_text']:
trailing_text_id = 'trailing_text_' + self.input_id
descriptions.append(trailing_text_id)
# Every list should contain the status id # Every list should contain the status id
status_id = 'status_' + self.input_id status_id = 'status_' + self.input_id
descriptions = list([status_id]) descriptions.append(status_id)
descriptions.extend(self.response_data.get('descriptions', {}).keys()) descriptions.extend(self.response_data.get('descriptions', {}).keys())
description_ids = ' '.join(descriptions) description_ids = ' '.join(descriptions)
context.update( context.update(
......
...@@ -16,7 +16,7 @@ ...@@ -16,7 +16,7 @@
size="${size}" size="${size}"
% endif % endif
/> />
<span class="trailing_text">${trailing_text}</span> <span class="trailing_text" id="trailing_text_${id}">${trailing_text}</span>
<%include file="status_span.html" args="status=status, status_id=id"/> <%include file="status_span.html" args="status=status, status_id=id"/>
......
...@@ -34,7 +34,7 @@ ...@@ -34,7 +34,7 @@
style="display:none;" style="display:none;"
% endif % endif
/> />
<span class="trailing_text">${trailing_text}</span> <span class="trailing_text" id="trailing_text_${id}">${trailing_text}</span>
<%include file="status_span.html" args="status=status, status_id=id"/> <%include file="status_span.html" args="status=status, status_id=id"/>
......
...@@ -37,6 +37,8 @@ lookup_tag = inputtypes.registry.get_class_for_tag ...@@ -37,6 +37,8 @@ lookup_tag = inputtypes.registry.get_class_for_tag
DESCRIBEDBY = HTML('aria-describedby="status_{status_id} desc-1 desc-2"') DESCRIBEDBY = HTML('aria-describedby="status_{status_id} desc-1 desc-2"')
# Use TRAILING_TEXT_DESCRIBEDBY when trailing_text is not null
TRAILING_TEXT_DESCRIBEDBY = HTML('aria-describedby="trailing_text_{trailing_text_id} status_{status_id} desc-1 desc-2"')
DESCRIPTIONS = OrderedDict([('desc-1', 'description text 1'), ('desc-2', 'description text 2')]) DESCRIPTIONS = OrderedDict([('desc-1', 'description text 1'), ('desc-2', 'description text 2')])
RESPONSE_DATA = { RESPONSE_DATA = {
'label': 'question text 101', 'label': 'question text 101',
...@@ -361,7 +363,7 @@ class TextLineTest(unittest.TestCase): ...@@ -361,7 +363,7 @@ class TextLineTest(unittest.TestCase):
'trailing_text': expected_text, 'trailing_text': expected_text,
'preprocessor': None, 'preprocessor': None,
'response_data': RESPONSE_DATA, 'response_data': RESPONSE_DATA,
'describedby_html': DESCRIBEDBY.format(status_id=prob_id) 'describedby_html': TRAILING_TEXT_DESCRIBEDBY.format(trailing_text_id=prob_id, status_id=prob_id)
} }
self.assertEqual(context, expected) self.assertEqual(context, expected)
...@@ -1295,7 +1297,7 @@ class FormulaEquationTest(unittest.TestCase): ...@@ -1295,7 +1297,7 @@ class FormulaEquationTest(unittest.TestCase):
'inline': False, 'inline': False,
'trailing_text': expected_text, 'trailing_text': expected_text,
'response_data': RESPONSE_DATA, 'response_data': RESPONSE_DATA,
'describedby_html': DESCRIBEDBY.format(status_id=prob_id) 'describedby_html': TRAILING_TEXT_DESCRIBEDBY.format(trailing_text_id=prob_id, status_id=prob_id)
} }
self.assertEqual(context, expected) self.assertEqual(context, expected)
......
...@@ -13,7 +13,7 @@ from contextlib import contextmanager ...@@ -13,7 +13,7 @@ from contextlib import contextmanager
from uuid import uuid4 from uuid import uuid4
from factory import Factory, Sequence, lazy_attribute_sequence, lazy_attribute from factory import Factory, Sequence, lazy_attribute_sequence, lazy_attribute
from factory.containers import CyclicDefinitionError from factory.errors import CyclicDefinitionError
from mock import patch from mock import patch
from nose.tools import assert_less_equal, assert_greater_equal from nose.tools import assert_less_equal, assert_greater_equal
import dogstats_wrapper as dog_stats_api import dogstats_wrapper as dog_stats_api
......
...@@ -296,11 +296,9 @@ def copy_or_rename_transcript(new_name, old_name, item, delete_old=False, user=N ...@@ -296,11 +296,9 @@ def copy_or_rename_transcript(new_name, old_name, item, delete_old=False, user=N
def get_html5_ids(html5_sources): def get_html5_ids(html5_sources):
""" """
Helper method to parse out an HTML5 source into the ideas Helper method to parse out an HTML5 source into the ideas
NOTE: This assumes that '/' are not in the filename. NOTE: This assumes that '/' are not in the filename
Slices each id by 150, restricting too long strings as video names.
""" """
html5_ids = [x.split('/')[-1].rsplit('.', 1)[0][:150] for x in html5_sources] html5_ids = [x.split('/')[-1].rsplit('.', 1)[0] for x in html5_sources]
return html5_ids return html5_ids
......
...@@ -93,6 +93,7 @@ ...@@ -93,6 +93,7 @@
this.courseSettings = options.courseSettings; this.courseSettings = options.courseSettings;
this.hideRefineBar = options.hideRefineBar; this.hideRefineBar = options.hideRefineBar;
this.supportsActiveThread = options.supportsActiveThread; this.supportsActiveThread = options.supportsActiveThread;
this.hideReadState = options.hideReadState || false;
this.displayedCollection = new Discussion(this.collection.models, { this.displayedCollection = new Discussion(this.collection.models, {
pages: this.collection.pages pages: this.collection.pages
}); });
...@@ -342,7 +343,8 @@ ...@@ -342,7 +343,8 @@
neverRead: neverRead, neverRead: neverRead,
threadUrl: thread.urlFor('retrieve'), threadUrl: thread.urlFor('retrieve'),
threadPreview: threadPreview, threadPreview: threadPreview,
showThreadPreview: this.showThreadPreview showThreadPreview: this.showThreadPreview,
hideReadState: this.hideReadState
}, },
thread.toJSON() thread.toJSON()
); );
......
...@@ -169,6 +169,7 @@ ...@@ -169,6 +169,7 @@
}); });
return this.view.render(); return this.view.render();
}); });
setupAjax = function(callback) { setupAjax = function(callback) {
return $.ajax.and.callFake(function(params) { return $.ajax.and.callFake(function(params) {
if (callback) { if (callback) {
...@@ -185,19 +186,27 @@ ...@@ -185,19 +186,27 @@
}; };
}); });
}; };
renderSingleThreadWithProps = function(props) { renderSingleThreadWithProps = function(props) {
return makeView(new Discussion([new Thread(DiscussionViewSpecHelper.makeThreadWithProps(props))])).render(); return makeView(new Discussion([new Thread(DiscussionViewSpecHelper.makeThreadWithProps(props))])).render();
}; };
makeView = function(discussion) {
return new DiscussionThreadListView({ makeView = function(discussion, props) {
el: $('#fixture-element'), return new DiscussionThreadListView(
collection: discussion, _.extend(
showThreadPreview: true, {
courseSettings: new DiscussionCourseSettings({ el: $('#fixture-element'),
is_cohorted: true collection: discussion,
}) showThreadPreview: true,
}); courseSettings: new DiscussionCourseSettings({
is_cohorted: true
})
},
props
)
);
}; };
expectFilter = function(filterVal) { expectFilter = function(filterVal) {
return $.ajax.and.callFake(function(params) { return $.ajax.and.callFake(function(params) {
_.each(['unread', 'unanswered', 'flagged'], function(paramName) { _.each(['unread', 'unanswered', 'flagged'], function(paramName) {
...@@ -681,5 +690,45 @@ ...@@ -681,5 +690,45 @@
expect(view.$el.find('.thread-preview-body').length).toEqual(0); expect(view.$el.find('.thread-preview-body').length).toEqual(0);
}); });
}); });
describe('read/unread state', function() {
it('adds never-read class to unread threads', function() {
var unreads = this.threads.filter(function(thread) {
return !thread.read && thread.unread_comments_count === thread.comments_count;
}).length;
this.view = makeView(new Discussion(this.threads));
this.view.render();
expect(this.view.$('.never-read').length).toEqual(unreads);
});
it('shows a "x new" message for threads that are read, but have unread comments', function() {
var unreadThread = this.threads.filter(function(thread) {
return thread.read && thread.unread_comments_count !== thread.comments_count;
})[0],
newCommentsOnUnreadThread = unreadThread.unread_comments_count;
this.view = makeView(new Discussion(this.threads));
this.view.render();
expect(
this.view.$('.forum-nav-thread-unread-comments-count')
.first()
.text()
.trim()
).toEqual(newCommentsOnUnreadThread + ' new');
});
it('should display every thread as read if hideReadState: true is passed to the constructor', function() {
this.view = makeView(new Discussion(this.threads), {hideReadState: true});
this.view.render();
expect(this.view.$('.never-read').length).toEqual(0);
});
it('does not show the "x new" indicator for any thread if hideReadState: true is passed', function() {
this.view = makeView(new Discussion(this.threads), {hideReadState: true});
this.view.render();
expect(this.view.$('.forum-nav-thread-unread-comments-count').length).toEqual(0);
});
});
}); });
}).call(this); }).call(this);
<li data-id="<%- id %>" class="forum-nav-thread<% if (neverRead) { %> never-read<% } %>"> <li data-id="<%- id %>" class="forum-nav-thread<% if (!hideReadState && neverRead) { %> never-read<% } %>">
<a href="<%- threadUrl %>" class="forum-nav-thread-link"> <a href="<%- threadUrl %>" class="forum-nav-thread-link">
<div class="forum-nav-thread-wrapper-0"> <div class="forum-nav-thread-wrapper-0">
<% <%
...@@ -75,7 +75,7 @@ ...@@ -75,7 +75,7 @@
%> %>
</span> </span>
<% if (!neverRead && unread_comments_count > 0) { %> <% if (!hideReadState && !neverRead && unread_comments_count > 0) { %>
<span class="forum-nav-thread-unread-comments-count"> <span class="forum-nav-thread-unread-comments-count">
<%- <%-
StringUtils.interpolate( StringUtils.interpolate(
......
...@@ -69,25 +69,12 @@ def is_youtube_available(): ...@@ -69,25 +69,12 @@ def is_youtube_available():
bool: bool:
""" """
# Skip all the youtube tests for now because they are failing intermittently
youtube_api_urls = { # due to changes on their side. See: TE-1927
'main': 'https://www.youtube.com/', # TODO: Design and implement a better solution that is reliable and repeatable,
'player': 'https://www.youtube.com/iframe_api', # reflects how the application works in production, and limits the third-party
# For transcripts, you need to check an actual video, so we will # network traffic (e.g. repeatedly retrieving the js from youtube from the browser).
# just specify our default video and see if that one is available. return False
'transcript': 'http://video.google.com/timedtext?lang=en&v=3_yD_cEKoCk',
}
for url in youtube_api_urls.itervalues():
try:
response = requests.get(url, allow_redirects=False)
except requests.exceptions.ConnectionError:
return False
if response.status_code >= 300:
return False
return True
def is_focused_on_element(browser, selector): def is_focused_on_element(browser, selector):
......
...@@ -39,7 +39,10 @@ ...@@ -39,7 +39,10 @@
collection: this.discussion, collection: this.discussion,
el: this.$('.inline-threads'), el: this.$('.inline-threads'),
courseSettings: this.courseSettings, courseSettings: this.courseSettings,
hideRefineBar: true // TODO: re-enable the search/filter bar when it works correctly hideRefineBar: true, // TODO: re-enable the search/filter bar when it works correctly
// @TODO: On the profile page, thread read state for the viewing user is not accessible via API.
// Fix this when the Discussions API can support this query. Until then, hide read state.
hideReadState: true
}).render(); }).render();
this.discussionThreadListView.on('thread:selected', _.bind(this.navigateToThread, this)); this.discussionThreadListView.on('thread:selected', _.bind(this.navigateToThread, this));
......
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('email_marketing', '0003_auto_20160715_1145'),
]
operations = [
migrations.AddField(
model_name='emailmarketingconfiguration',
name='welcome_email_send_delay',
field=models.IntegerField(default=600, help_text='Number of seconds to delay the sending of User Welcome email after user has been activated'),
),
]
...@@ -127,6 +127,16 @@ class EmailMarketingConfiguration(ConfigurationModel): ...@@ -127,6 +127,16 @@ class EmailMarketingConfiguration(ConfigurationModel):
) )
) )
# The number of seconds to delay for welcome emails sending. This is needed to acommendate those
# learners who created user account during course enrollment so we can send a different message
# in our welcome email.
welcome_email_send_delay = models.fields.IntegerField(
default=600,
help_text=_(
"Number of seconds to delay the sending of User Welcome email after user has been activated"
)
)
def __unicode__(self): def __unicode__(self):
return u"Email marketing configuration: New user list %s, Activation template: %s" % \ return u"Email marketing configuration: New user list %s, Activation template: %s" % \
(self.sailthru_new_user_list, self.sailthru_activation_template) (self.sailthru_new_user_list, self.sailthru_activation_template)
...@@ -3,6 +3,7 @@ This file contains celery tasks for email marketing signal handler. ...@@ -3,6 +3,7 @@ This file contains celery tasks for email marketing signal handler.
""" """
import logging import logging
import time import time
from datetime import datetime, timedelta
from celery import task from celery import task
from django.core.cache import cache from django.core.cache import cache
...@@ -56,10 +57,16 @@ def update_user(self, sailthru_vars, email, site=None, new_user=False, activatio ...@@ -56,10 +57,16 @@ def update_user(self, sailthru_vars, email, site=None, new_user=False, activatio
# if activating user, send welcome email # if activating user, send welcome email
if activation and email_config.sailthru_activation_template: if activation and email_config.sailthru_activation_template:
scheduled_datetime = datetime.utcnow() + timedelta(seconds=email_config.welcome_email_send_delay)
try: try:
sailthru_response = sailthru_client.api_post("send", sailthru_response = sailthru_client.api_post(
{"email": email, "send",
"template": email_config.sailthru_activation_template}) {
"email": email,
"template": email_config.sailthru_activation_template,
"schedule_time": scheduled_datetime.strftime('%Y-%m-%dT%H:%M:%SZ')
}
)
except SailthruClientError as exc: except SailthruClientError as exc:
log.error("Exception attempting to send welcome email to user %s in Sailthru - %s", email, unicode(exc)) log.error("Exception attempting to send welcome email to user %s in Sailthru - %s", email, unicode(exc))
raise self.retry(exc=exc, raise self.retry(exc=exc,
......
"""Tests of email marketing signal handlers.""" """Tests of email marketing signal handlers."""
import ddt import ddt
import logging import logging
import datetime
from django.test import TestCase from django.test import TestCase
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
...@@ -45,6 +46,7 @@ def update_email_marketing_config(enabled=True, key='badkey', secret='badsecret' ...@@ -45,6 +46,7 @@ def update_email_marketing_config(enabled=True, key='badkey', secret='badsecret'
sailthru_get_tags_from_sailthru=False, sailthru_get_tags_from_sailthru=False,
sailthru_enroll_cost=enroll_cost, sailthru_enroll_cost=enroll_cost,
sailthru_max_retries=0, sailthru_max_retries=0,
welcome_email_send_delay=600
) )
...@@ -168,12 +170,14 @@ class EmailMarketingTests(TestCase): ...@@ -168,12 +170,14 @@ class EmailMarketingTests(TestCase):
""" """
mock_sailthru_post.return_value = SailthruResponse(JsonResponse({'ok': True})) mock_sailthru_post.return_value = SailthruResponse(JsonResponse({'ok': True}))
mock_sailthru_get.return_value = SailthruResponse(JsonResponse({'lists': [{'name': 'new list'}], 'ok': True})) mock_sailthru_get.return_value = SailthruResponse(JsonResponse({'lists': [{'name': 'new list'}], 'ok': True}))
expected_schedule = datetime.datetime.utcnow() + datetime.timedelta(seconds=600)
update_user.delay({}, self.user.email, new_user=True, activation=True) update_user.delay({}, self.user.email, new_user=True, activation=True)
# look for call args for 2nd call # look for call args for 2nd call
self.assertEquals(mock_sailthru_post.call_args[0][0], "send") self.assertEquals(mock_sailthru_post.call_args[0][0], "send")
userparms = mock_sailthru_post.call_args[0][1] userparms = mock_sailthru_post.call_args[0][1]
self.assertEquals(userparms['email'], TEST_EMAIL) self.assertEquals(userparms['email'], TEST_EMAIL)
self.assertEquals(userparms['template'], "Activation") self.assertEquals(userparms['template'], "Activation")
self.assertEquals(userparms['schedule_time'], expected_schedule.strftime('%Y-%m-%dT%H:%M:%SZ'))
@patch('email_marketing.tasks.log.error') @patch('email_marketing.tasks.log.error')
@patch('email_marketing.tasks.SailthruClient.api_post') @patch('email_marketing.tasks.SailthruClient.api_post')
......
"""
Reset persistent grades for learners.
"""
from datetime import datetime
import logging
from textwrap import dedent
from django.core.management.base import BaseCommand, CommandError
from django.db.models import Count
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from lms.djangoapps.grades.models import PersistentSubsectionGrade, PersistentCourseGrade
log = logging.getLogger(__name__)
DATE_FORMAT = "%Y-%m-%d %H:%M"
class Command(BaseCommand):
"""
Reset persistent grades for learners.
"""
help = dedent(__doc__).strip()
def add_arguments(self, parser):
"""
Add arguments to the command parser.
"""
parser.add_argument(
'--dry_run',
action='store_true',
default=False,
dest='dry_run',
help="Output what we're going to do, but don't actually do it. To actually delete, use --delete instead."
)
parser.add_argument(
'--delete',
action='store_true',
default=False,
dest='delete',
help="Actually perform the deletions of the course. For a Dry Run, use --dry_run instead."
)
parser.add_argument(
'--courses',
dest='courses',
nargs='+',
help='Reset persistent grades for the list of courses provided.',
)
parser.add_argument(
'--all_courses',
action='store_true',
dest='all_courses',
default=False,
help='Reset persistent grades for all courses.',
)
parser.add_argument(
'--modified_start',
dest='modified_start',
help='Starting range for modified date (inclusive): e.g. "2016-08-23 16:43"',
)
parser.add_argument(
'--modified_end',
dest='modified_end',
help='Ending range for modified date (inclusive): e.g. "2016-12-23 16:43"',
)
def handle(self, *args, **options):
course_keys = None
modified_start = None
modified_end = None
run_mode = self._get_mutually_exclusive_option(options, 'delete', 'dry_run')
courses_mode = self._get_mutually_exclusive_option(options, 'courses', 'all_courses')
if options.get('modified_start'):
modified_start = datetime.strptime(options['modified_start'], DATE_FORMAT)
if options.get('modified_end'):
if not modified_start:
raise CommandError('Optional value for modified_end provided without a value for modified_start.')
modified_end = datetime.strptime(options['modified_end'], DATE_FORMAT)
if courses_mode == 'courses':
try:
course_keys = [CourseKey.from_string(course_key_string) for course_key_string in options['courses']]
except InvalidKeyError as error:
raise CommandError('Invalid key specified: {}'.format(error.message))
log.info("reset_grade: Started in %s mode!", run_mode)
operation = self._query_grades if run_mode == 'dry_run' else self._delete_grades
operation(PersistentSubsectionGrade, course_keys, modified_start, modified_end)
operation(PersistentCourseGrade, course_keys, modified_start, modified_end)
log.info("reset_grade: Finished in %s mode!", run_mode)
def _delete_grades(self, grade_model_class, *args, **kwargs):
"""
Deletes the requested grades in the given model, filtered by the provided args and kwargs.
"""
grades_query_set = grade_model_class.query_grades(*args, **kwargs)
num_rows_to_delete = grades_query_set.count()
log.info("reset_grade: Deleting %s: %d row(s).", grade_model_class.__name__, num_rows_to_delete)
grade_model_class.delete_grades(*args, **kwargs)
log.info("reset_grade: Deleted %s: %d row(s).", grade_model_class.__name__, num_rows_to_delete)
def _query_grades(self, grade_model_class, *args, **kwargs):
"""
Queries the requested grades in the given model, filtered by the provided args and kwargs.
"""
total_for_all_courses = 0
grades_query_set = grade_model_class.query_grades(*args, **kwargs)
grades_stats = grades_query_set.values('course_id').order_by().annotate(total=Count('course_id'))
for stat in grades_stats:
total_for_all_courses += stat['total']
log.info(
"reset_grade: Would delete %s for COURSE %s: %d row(s).",
grade_model_class.__name__,
stat['course_id'],
stat['total'],
)
log.info(
"reset_grade: Would delete %s in TOTAL: %d row(s).",
grade_model_class.__name__,
total_for_all_courses,
)
def _get_mutually_exclusive_option(self, options, option_1, option_2):
"""
Validates that exactly one of the 2 given options is specified.
Returns the name of the found option.
"""
if not options.get(option_1) and not options.get(option_2):
raise CommandError('Either --{} or --{} must be specified.'.format(option_1, option_2))
if options.get(option_1) and options.get(option_2):
raise CommandError('Both --{} and --{} cannot be specified.'.format(option_1, option_2))
return option_1 if options.get(option_1) else option_2
...@@ -38,6 +38,38 @@ BLOCK_RECORD_LIST_VERSION = 1 ...@@ -38,6 +38,38 @@ BLOCK_RECORD_LIST_VERSION = 1
BlockRecord = namedtuple('BlockRecord', ['locator', 'weight', 'raw_possible', 'graded']) BlockRecord = namedtuple('BlockRecord', ['locator', 'weight', 'raw_possible', 'graded'])
class DeleteGradesMixin(object):
"""
A Mixin class that provides functionality to delete grades.
"""
@classmethod
def query_grades(cls, course_ids=None, modified_start=None, modified_end=None):
"""
Queries all the grades in the table, filtered by the provided arguments.
"""
kwargs = {}
if course_ids:
kwargs['course_id__in'] = [course_id for course_id in course_ids]
if modified_start:
if modified_end:
kwargs['modified__range'] = (modified_start, modified_end)
else:
kwargs['modified__gt'] = modified_start
return cls.objects.filter(**kwargs)
@classmethod
def delete_grades(cls, *args, **kwargs):
"""
Deletes all the grades in the table, filtered by the provided arguments.
"""
query = cls.query_grades(*args, **kwargs)
query.delete()
class BlockRecordList(tuple): class BlockRecordList(tuple):
""" """
An immutable ordered list of BlockRecord objects. An immutable ordered list of BlockRecord objects.
...@@ -208,7 +240,7 @@ class VisibleBlocks(models.Model): ...@@ -208,7 +240,7 @@ class VisibleBlocks(models.Model):
cls.bulk_create(non_existent_brls) cls.bulk_create(non_existent_brls)
class PersistentSubsectionGrade(TimeStampedModel): class PersistentSubsectionGrade(DeleteGradesMixin, TimeStampedModel):
""" """
A django model tracking persistent grades at the subsection level. A django model tracking persistent grades at the subsection level.
""" """
...@@ -458,7 +490,7 @@ class PersistentSubsectionGrade(TimeStampedModel): ...@@ -458,7 +490,7 @@ class PersistentSubsectionGrade(TimeStampedModel):
) )
class PersistentCourseGrade(TimeStampedModel): class PersistentCourseGrade(DeleteGradesMixin, TimeStampedModel):
""" """
A django model tracking persistent course grades. A django model tracking persistent course grades.
""" """
......
...@@ -12,6 +12,7 @@ from lazy import lazy ...@@ -12,6 +12,7 @@ from lazy import lazy
from lms.djangoapps.course_blocks.api import get_course_blocks from lms.djangoapps.course_blocks.api import get_course_blocks
from lms.djangoapps.grades.config.models import PersistentGradesEnabledFlag from lms.djangoapps.grades.config.models import PersistentGradesEnabledFlag
from openedx.core.djangoapps.content.block_structure.api import get_block_structure_manager
from openedx.core.djangoapps.signals.signals import COURSE_GRADE_CHANGED from openedx.core.djangoapps.signals.signals import COURSE_GRADE_CHANGED
from xmodule import block_metadata_utils from xmodule import block_metadata_utils
...@@ -323,17 +324,23 @@ class CourseGradeFactory(object): ...@@ -323,17 +324,23 @@ class CourseGradeFactory(object):
""" """
Factory class to create Course Grade objects Factory class to create Course Grade objects
""" """
def create(self, student, course, read_only=True): def create(self, student, course, collected_block_structure=None, read_only=True):
""" """
Returns the CourseGrade object for the given student and course. Returns the CourseGrade object for the given student and course.
If read_only is True, doesn't save any updates to the grades. If read_only is True, doesn't save any updates to the grades.
Raises a PermissionDenied if the user does not have course access. Raises a PermissionDenied if the user does not have course access.
""" """
course_structure = get_course_blocks(student, course.location) course_structure = get_course_blocks(
student,
course.location,
collected_block_structure=collected_block_structure,
)
# if user does not have access to this course, throw an exception # if user does not have access to this course, throw an exception
if not self._user_has_access_to_course(course_structure): if not self._user_has_access_to_course(course_structure):
raise PermissionDenied("User does not have access to this course") raise PermissionDenied("User does not have access to this course")
return ( return (
self._get_saved_grade(student, course, course_structure) or self._get_saved_grade(student, course, course_structure) or
self._compute_and_update_grade(student, course, course_structure, read_only) self._compute_and_update_grade(student, course, course_structure, read_only)
...@@ -351,11 +358,17 @@ class CourseGradeFactory(object): ...@@ -351,11 +358,17 @@ class CourseGradeFactory(object):
If an error occurred, course_grade will be None and err_msg will be an If an error occurred, course_grade will be None and err_msg will be an
exception message. If there was no error, err_msg is an empty string. exception message. If there was no error, err_msg is an empty string.
""" """
# Pre-fetch the collected course_structure so:
# 1. Correctness: the same version of the course is used to
# compute the grade for all students.
# 2. Optimization: the collected course_structure is not
# retrieved from the data store multiple times.
collected_block_structure = get_block_structure_manager(course.id).get_collected()
for student in students: for student in students:
with dog_stats_api.timer('lms.grades.CourseGradeFactory.iter', tags=[u'action:{}'.format(course.id)]): with dog_stats_api.timer('lms.grades.CourseGradeFactory.iter', tags=[u'action:{}'.format(course.id)]):
try: try:
course_grade = CourseGradeFactory().create(student, course) course_grade = CourseGradeFactory().create(student, course, collected_block_structure)
yield self.GradeResult(student, course_grade, "") yield self.GradeResult(student, course_grade, "")
except Exception as exc: # pylint: disable=broad-except except Exception as exc: # pylint: disable=broad-except
......
...@@ -12,6 +12,7 @@ from courseware.model_data import set_score ...@@ -12,6 +12,7 @@ from courseware.model_data import set_score
from courseware.tests.helpers import LoginEnrollmentTestCase from courseware.tests.helpers import LoginEnrollmentTestCase
from lms.djangoapps.course_blocks.api import get_course_blocks from lms.djangoapps.course_blocks.api import get_course_blocks
from openedx.core.lib.block_structure.factory import BlockStructureFactory
from openedx.core.djangolib.testing.utils import get_mock_request from openedx.core.djangolib.testing.utils import get_mock_request
from student.tests.factories import UserFactory from student.tests.factories import UserFactory
from student.models import CourseEnrollment from student.models import CourseEnrollment
...@@ -68,7 +69,14 @@ class TestGradeIteration(SharedModuleStoreTestCase): ...@@ -68,7 +69,14 @@ class TestGradeIteration(SharedModuleStoreTestCase):
""" """
No students have grade entries. No students have grade entries.
""" """
all_course_grades, all_errors = self._course_grades_and_errors_for(self.course, self.students) with patch.object(
BlockStructureFactory,
'create_from_cache',
wraps=BlockStructureFactory.create_from_cache
) as mock_create_from_cache:
all_course_grades, all_errors = self._course_grades_and_errors_for(self.course, self.students)
self.assertEquals(mock_create_from_cache.call_count, 1)
self.assertEqual(len(all_errors), 0) self.assertEqual(len(all_errors), 0)
for course_grade in all_course_grades.values(): for course_grade in all_course_grades.values():
self.assertIsNone(course_grade.letter_grade) self.assertIsNone(course_grade.letter_grade)
......
...@@ -333,6 +333,8 @@ COMMENTS_SERVICE_URL = ENV_TOKENS.get("COMMENTS_SERVICE_URL", '') ...@@ -333,6 +333,8 @@ COMMENTS_SERVICE_URL = ENV_TOKENS.get("COMMENTS_SERVICE_URL", '')
COMMENTS_SERVICE_KEY = ENV_TOKENS.get("COMMENTS_SERVICE_KEY", '') COMMENTS_SERVICE_KEY = ENV_TOKENS.get("COMMENTS_SERVICE_KEY", '')
CERT_QUEUE = ENV_TOKENS.get("CERT_QUEUE", 'test-pull') CERT_QUEUE = ENV_TOKENS.get("CERT_QUEUE", 'test-pull')
ZENDESK_URL = ENV_TOKENS.get('ZENDESK_URL', ZENDESK_URL) ZENDESK_URL = ENV_TOKENS.get('ZENDESK_URL', ZENDESK_URL)
ZENDESK_CUSTOM_FIELDS = ENV_TOKENS.get('ZENDESK_CUSTOM_FIELDS', ZENDESK_CUSTOM_FIELDS)
FEEDBACK_SUBMISSION_EMAIL = ENV_TOKENS.get("FEEDBACK_SUBMISSION_EMAIL") FEEDBACK_SUBMISSION_EMAIL = ENV_TOKENS.get("FEEDBACK_SUBMISSION_EMAIL")
MKTG_URLS = ENV_TOKENS.get('MKTG_URLS', MKTG_URLS) MKTG_URLS = ENV_TOKENS.get('MKTG_URLS', MKTG_URLS)
......
...@@ -76,6 +76,17 @@ PIPELINE_JS_COMPRESSOR = None ...@@ -76,6 +76,17 @@ PIPELINE_JS_COMPRESSOR = None
CELERY_ALWAYS_EAGER = True CELERY_ALWAYS_EAGER = True
CELERY_RESULT_BACKEND = 'djcelery.backends.cache:CacheBackend' CELERY_RESULT_BACKEND = 'djcelery.backends.cache:CacheBackend'
BLOCK_STRUCTURES_SETTINGS = dict(
# We have CELERY_ALWAYS_EAGER set to True, so there's no asynchronous
# code running and the celery routing is unimportant.
# It does not make sense to retry.
BLOCK_STRUCTURES_TASK_MAX_RETRIES=0,
# course publish task delay is irrelevant is because the task is run synchronously
BLOCK_STRUCTURES_COURSE_PUBLISH_TASK_DELAY=0,
# retry delay is irrelevent because we never retry
BLOCK_STRUCTURES_TASK_DEFAULT_RETRY_DELAY=0,
)
###################### Grade Downloads ###################### ###################### Grade Downloads ######################
GRADES_DOWNLOAD = { GRADES_DOWNLOAD = {
'STORAGE_TYPE': 'localfs', 'STORAGE_TYPE': 'localfs',
......
...@@ -1003,6 +1003,7 @@ FEEDBACK_SUBMISSION_EMAIL = None ...@@ -1003,6 +1003,7 @@ FEEDBACK_SUBMISSION_EMAIL = None
ZENDESK_URL = None ZENDESK_URL = None
ZENDESK_USER = None ZENDESK_USER = None
ZENDESK_API_KEY = None ZENDESK_API_KEY = None
ZENDESK_CUSTOM_FIELDS = {}
##### EMBARGO ##### ##### EMBARGO #####
EMBARGO_SITE_REDIRECT_URL = None EMBARGO_SITE_REDIRECT_URL = None
......
...@@ -35,6 +35,7 @@ ...@@ -35,6 +35,7 @@
@import 'shared/modal'; @import 'shared/modal';
@import 'shared/activation_messages'; @import 'shared/activation_messages';
@import 'shared/unsubscribe'; @import 'shared/unsubscribe';
@import 'shared/help-tab';
// shared - platform // shared - platform
@import 'multicourse/home'; @import 'multicourse/home';
......
...@@ -78,3 +78,9 @@ ...@@ -78,3 +78,9 @@
padding: 0 $baseline $baseline; padding: 0 $baseline $baseline;
} }
} }
.feedback-form-select {
background: $white;
margin-bottom: $baseline;
width: 100%;
}
.feedback-form-select {
background: $white;
margin-bottom: $baseline;
width: 100%;
}
...@@ -99,15 +99,21 @@ from xmodule.tabs import CourseTabList ...@@ -99,15 +99,21 @@ from xmodule.tabs import CourseTabList
<label data-field="email" for="feedback_form_email">${_('E-mail')}*</label> <label data-field="email" for="feedback_form_email">${_('E-mail')}*</label>
<input name="email" type="text" id="feedback_form_email" required> <input name="email" type="text" id="feedback_form_email" required>
% endif % endif
<div class="js-course-id-anchor">
% if course:
<input name="course_id" type="hidden" value="${unicode(course.id)}">
% endif
</div>
<label data-field="subject" for="feedback_form_subject">${_('Briefly describe your issue')}*</label> <label data-field="subject" for="feedback_form_subject">${_('Briefly describe your issue')}*</label>
<input name="subject" type="text" id="feedback_form_subject" required> <input name="subject" type="text" id="feedback_form_subject" required>
<label data-field="details" for="feedback_form_details">${_('Tell us the details')}*</label> <label data-field="details" for="feedback_form_details">${_('Tell us the details')}*</label>
<span class="tip" id="feedback_form_details_tip">${_('Describe what you were doing when you encountered the issue. Include any details that will help us to troubleshoot, including error messages that you saw.')}</span> <span class="tip" id="feedback_form_details_tip">${_('Describe what you were doing when you encountered the issue. Include any details that will help us to troubleshoot, including error messages that you saw.')}</span>
<textarea name="details" id="feedback_form_details" required aria-describedby="feedback_form_details_tip"></textarea> <textarea name="details" id="feedback_form_details" required aria-describedby="feedback_form_details_tip"></textarea>
<input name="issue_type" type="hidden"> <input name="issue_type" type="hidden">
% if course:
<input name="course_id" type="hidden" value="${unicode(course.id)}">
% endif
<div class="submit"> <div class="submit">
<input name="submit" type="submit" value="${_('Submit')}" id="feedback_submit"> <input name="submit" type="submit" value="${_('Submit')}" id="feedback_submit">
</div> </div>
...@@ -138,7 +144,12 @@ from xmodule.tabs import CourseTabList ...@@ -138,7 +144,12 @@ from xmodule.tabs import CourseTabList
<script type="text/javascript"> <script type="text/javascript">
$(document).ready(function() { $(document).ready(function() {
var $helpModal = $("#help-modal"), var currentCourseId,
courseOptions = [],
userAuthenticated = false,
courseOptionsLoadInProgress = false,
finishedLoadingCourseOptions = false,
$helpModal = $("#help-modal"),
$closeButton = $("#help-modal .close-modal"), $closeButton = $("#help-modal .close-modal"),
$leanOverlay = $("#lean_overlay"), $leanOverlay = $("#lean_overlay"),
$feedbackForm = $("#feedback_form"), $feedbackForm = $("#feedback_form"),
...@@ -149,11 +160,101 @@ $(document).ready(function() { ...@@ -149,11 +160,101 @@ $(document).ready(function() {
$('area,input,select,textarea,button').removeAttr('tabindex'); $('area,input,select,textarea,button').removeAttr('tabindex');
$(".help-tab a").focus(); $(".help-tab a").focus();
$leanOverlay.removeAttr('tabindex'); $leanOverlay.removeAttr('tabindex');
},
showFeedback = function(event, issue_type, title, subject_label, details_label) {
event.preventDefault();
DialogTabControls.initializeTabKeyValues("#feedback_form_wrapper", $closeButton);
$("#feedback_form input[name='issue_type']").val(issue_type);
$("#feedback_form_wrapper header").html("<h2>" + title + "</h2><hr>");
$("#feedback_form_wrapper label[data-field='subject']").html(subject_label);
$("#feedback_form_wrapper label[data-field='details']").html(details_label);
if (userAuthenticated && finishedLoadingCourseOptions && courseOptions.length > 1) {
$('.js-course-id-anchor').html([
'<label for="feedback_form_course">' + '${_("Course") | n, js_escaped_string}' + '</label>',
'<select name="course_id" id="feedback_form_course" class="feedback-form-select">',
courseOptions.join(''),
'</select>'
].join(''));
}
$("#help_wrapper").css("display", "none");
$("#feedback_form_wrapper").css("display", "block");
$closeButton.focus();
},
loadCourseOptions = function() {
courseOptionsLoadInProgress = true;
$.ajax({
url: '/api/enrollment/v1/enrollment',
success: function(data) {
var i,
courseDetails,
courseName,
courseId,
option,
defaultOptionText = '${_("- Select -") | n, js_escaped_string}',
markedSelectedOption = false;
// Make sure courseOptions is empty before we begin pushing options into it.
courseOptions = [];
for (i = 0; i < data.length; i++) {
courseDetails = data[i].course_details;
if (!courseDetails) {
continue;
}
courseName = courseDetails.course_name;
courseId = courseDetails.course_id;
if (!(courseName && courseId)) {
continue;
}
// Build an option for this course and select it if it's the course we're currently viewing.
if (!markedSelectedOption && courseId === currentCourseId) {
option = buildCourseOption(courseName, courseId, true);
markedSelectedOption = true;
} else {
option = buildCourseOption(courseName, courseId, false);
}
courseOptions.push(option);
}
// Build the default option and select it if we haven't already selected another option.
option = buildCourseOption(defaultOptionText, '', !markedSelectedOption);
// Add the default option to the head of the courseOptions Array.
courseOptions.unshift(option);
finishedLoadingCourseOptions = true;
},
complete: function() {
courseOptionsLoadInProgress = false;
}
});
},
buildCourseOption = function(courseName, courseId, selected) {
var option = '<option value="' + _.escape(courseId) + '"';
if (selected) {
option += ' selected';
}
option += '>' + _.escape(courseName) + '</option>';
return option;
}; };
% if user.is_authenticated():
userAuthenticated = true;
% endif
% if course:
currentCourseId = "${unicode(course.id) | n, js_escaped_string}";
% endif
DialogTabControls.setKeydownListener($helpModal, $closeButton); DialogTabControls.setKeydownListener($helpModal, $closeButton);
$(".help-tab").click(function() { $(".help-tab").click(function() {
if (userAuthenticated && !finishedLoadingCourseOptions && !courseOptionsLoadInProgress) {
loadCourseOptions();
}
$helpModal.css("position", "absolute"); $helpModal.css("position", "absolute");
DialogTabControls.initializeTabKeyValues("#help_wrapper", $closeButton); DialogTabControls.initializeTabKeyValues("#help_wrapper", $closeButton);
$(".field-error").removeClass("field-error"); $(".field-error").removeClass("field-error");
...@@ -171,18 +272,6 @@ $(document).ready(function() { ...@@ -171,18 +272,6 @@ $(document).ready(function() {
$closeButton.focus(); $closeButton.focus();
}); });
showFeedback = function(event, issue_type, title, subject_label, details_label) {
$("#help_wrapper").css("display", "none");
DialogTabControls.initializeTabKeyValues("#feedback_form_wrapper", $closeButton);
$("#feedback_form input[name='issue_type']").val(issue_type);
$("#feedback_form_wrapper").css("display", "block");
$("#feedback_form_wrapper header").html("<h2>" + title + "</h2><hr>");
$("#feedback_form_wrapper label[data-field='subject']").html(subject_label);
$("#feedback_form_wrapper label[data-field='details']").html(details_label);
$closeButton.focus();
event.preventDefault();
};
$("#feedback_link_problem").click(function(event) { $("#feedback_link_problem").click(function(event) {
$("#feedback_form_details_tip").css({"display": "block", "padding-bottom": "5px"}); $("#feedback_form_details_tip").css({"display": "block", "padding-bottom": "5px"});
showFeedback( showFeedback(
......
...@@ -26,8 +26,8 @@ class PhotoVerificationStatusViewTests(TestCase): ...@@ -26,8 +26,8 @@ class PhotoVerificationStatusViewTests(TestCase):
def setUp(self): def setUp(self):
super(PhotoVerificationStatusViewTests, self).setUp() super(PhotoVerificationStatusViewTests, self).setUp()
self.user = UserFactory.create(password=self.PASSWORD) self.user = UserFactory(password=self.PASSWORD)
self.staff = UserFactory.create(is_staff=True, password=self.PASSWORD) self.staff = UserFactory(is_staff=True, password=self.PASSWORD)
self.verification = SoftwareSecurePhotoVerification.objects.create(user=self.user, status='submitted') self.verification = SoftwareSecurePhotoVerification.objects.create(user=self.user, status='submitted')
self.path = reverse('verification_status', kwargs={'username': self.user.username}) self.path = reverse('verification_status', kwargs={'username': self.user.username})
self.client.login(username=self.staff.username, password=self.PASSWORD) self.client.login(username=self.staff.username, password=self.PASSWORD)
...@@ -57,7 +57,7 @@ class PhotoVerificationStatusViewTests(TestCase): ...@@ -57,7 +57,7 @@ class PhotoVerificationStatusViewTests(TestCase):
def test_no_verifications(self): def test_no_verifications(self):
""" The endpoint should return HTTP 404 if the user has no verifications. """ """ The endpoint should return HTTP 404 if the user has no verifications. """
user = UserFactory.create() user = UserFactory()
path = reverse('verification_status', kwargs={'username': user.username}) path = reverse('verification_status', kwargs={'username': user.username})
self.assert_path_not_found(path) self.assert_path_not_found(path)
...@@ -69,17 +69,19 @@ class PhotoVerificationStatusViewTests(TestCase): ...@@ -69,17 +69,19 @@ class PhotoVerificationStatusViewTests(TestCase):
def test_staff_user(self): def test_staff_user(self):
""" The endpoint should be accessible to staff users. """ """ The endpoint should be accessible to staff users. """
self.client.logout()
self.client.login(username=self.staff.username, password=self.PASSWORD) self.client.login(username=self.staff.username, password=self.PASSWORD)
self.assert_verification_returned() self.assert_verification_returned()
def test_owner(self): def test_owner(self):
""" The endpoint should be accessible to the user who submitted the verification request. """ """ The endpoint should be accessible to the user who submitted the verification request. """
self.client.login(username=self.user.username, password=self.user.password) self.client.logout()
self.client.login(username=self.user.username, password=self.PASSWORD)
self.assert_verification_returned() self.assert_verification_returned()
def test_non_owner_or_staff_user(self): def test_non_owner_or_staff_user(self):
""" The endpoint should NOT be accessible if the request is not made by the submitter or staff user. """ """ The endpoint should NOT be accessible if the request is not made by the submitter or staff user. """
user = UserFactory.create() user = UserFactory()
self.client.login(username=user.username, password=self.PASSWORD) self.client.login(username=user.username, password=self.PASSWORD)
response = self.client.get(self.path) response = self.client.get(self.path)
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
...@@ -88,5 +90,6 @@ class PhotoVerificationStatusViewTests(TestCase): ...@@ -88,5 +90,6 @@ class PhotoVerificationStatusViewTests(TestCase):
""" The endpoint should return that the user is verified if the user's verification is accepted. """ """ The endpoint should return that the user is verified if the user's verification is accepted. """
self.verification.status = 'approved' self.verification.status = 'approved'
self.verification.save() self.verification.save()
self.client.login(username=self.user.username, password=self.user.password) self.client.logout()
self.client.login(username=self.user.username, password=self.PASSWORD)
self.assert_verification_returned(verified=True) self.assert_verification_returned(verified=True)
...@@ -156,4 +156,5 @@ class IsStaffOrOwner(permissions.BasePermission): ...@@ -156,4 +156,5 @@ class IsStaffOrOwner(permissions.BasePermission):
user = request.user user = request.user
return user.is_staff \ return user.is_staff \
or (user.username == request.GET.get('username')) \ or (user.username == request.GET.get('username')) \
or (user.username == getattr(request, 'data', {}).get('username')) or (user.username == getattr(request, 'data', {}).get('username')) \
or (user.username == getattr(view, 'kwargs', {}).get('username'))
...@@ -5,6 +5,7 @@ from django.contrib.auth.models import AnonymousUser ...@@ -5,6 +5,7 @@ from django.contrib.auth.models import AnonymousUser
from django.http import Http404 from django.http import Http404
from django.test import TestCase, RequestFactory from django.test import TestCase, RequestFactory
from nose.plugins.attrib import attr from nose.plugins.attrib import attr
from rest_framework.generics import GenericAPIView
from student.roles import CourseStaffRole, CourseInstructorRole from student.roles import CourseStaffRole, CourseInstructorRole
from openedx.core.lib.api.permissions import ( from openedx.core.lib.api.permissions import (
...@@ -37,8 +38,8 @@ class IsCourseStaffInstructorTests(TestCase): ...@@ -37,8 +38,8 @@ class IsCourseStaffInstructorTests(TestCase):
def setUp(self): def setUp(self):
super(IsCourseStaffInstructorTests, self).setUp() super(IsCourseStaffInstructorTests, self).setUp()
self.permission = IsCourseStaffInstructor() self.permission = IsCourseStaffInstructor()
self.coach = UserFactory.create() self.coach = UserFactory()
self.user = UserFactory.create() self.user = UserFactory()
self.request = RequestFactory().get('/') self.request = RequestFactory().get('/')
self.request.user = self.user self.request.user = self.user
self.course_key = CourseKey.from_string('edx/test123/run') self.course_key = CourseKey.from_string('edx/test123/run')
...@@ -72,7 +73,7 @@ class IsMasterCourseStaffInstructorTests(TestCase): ...@@ -72,7 +73,7 @@ class IsMasterCourseStaffInstructorTests(TestCase):
super(IsMasterCourseStaffInstructorTests, self).setUp() super(IsMasterCourseStaffInstructorTests, self).setUp()
self.permission = IsMasterCourseStaffInstructor() self.permission = IsMasterCourseStaffInstructor()
master_course_id = 'edx/test123/run' master_course_id = 'edx/test123/run'
self.user = UserFactory.create() self.user = UserFactory()
self.get_request = RequestFactory().get('/?master_course_id={}'.format(master_course_id)) self.get_request = RequestFactory().get('/?master_course_id={}'.format(master_course_id))
self.get_request.user = self.user self.get_request.user = self.user
self.post_request = RequestFactory().post('/', data={'master_course_id': master_course_id}) self.post_request = RequestFactory().post('/', data={'master_course_id': master_course_id})
...@@ -133,36 +134,44 @@ class IsStaffOrOwnerTests(TestCase): ...@@ -133,36 +134,44 @@ class IsStaffOrOwnerTests(TestCase):
def test_staff_user(self): def test_staff_user(self):
""" Staff users should be permitted. """ """ Staff users should be permitted. """
user = UserFactory.create(is_staff=True) user = UserFactory(is_staff=True)
self.assert_user_has_object_permission(user, True) self.assert_user_has_object_permission(user, True)
def test_owner(self): def test_owner(self):
""" Owners should be permitted. """ """ Owners should be permitted. """
user = UserFactory.create() user = UserFactory()
self.obj.user = user self.obj.user = user
self.assert_user_has_object_permission(user, True) self.assert_user_has_object_permission(user, True)
def test_non_staff_test_non_owner_or_staff_user(self): def test_non_staff_test_non_owner_or_staff_user(self):
""" Non-staff and non-owner users should not be permitted. """ """ Non-staff and non-owner users should not be permitted. """
user = UserFactory.create() user = UserFactory()
self.assert_user_has_object_permission(user, False) self.assert_user_has_object_permission(user, False)
def test_has_permission_as_staff(self): def test_has_permission_as_staff(self):
""" Staff users always have permission. """ """ Staff users always have permission. """
self.request.user = UserFactory.create(is_staff=True) self.request.user = UserFactory(is_staff=True)
self.assertTrue(self.permission.has_permission(self.request, None)) self.assertTrue(self.permission.has_permission(self.request, None))
def test_has_permission_as_owner_with_get(self): def test_has_permission_as_owner_with_get(self):
""" Owners always have permission to make GET actions. """ """ Owners always have permission to make GET actions. """
user = UserFactory.create() user = UserFactory()
request = RequestFactory().get('/?username={}'.format(user.username)) request = RequestFactory().get('/?username={}'.format(user.username))
request.user = user request.user = user
self.assertTrue(self.permission.has_permission(request, None)) self.assertTrue(self.permission.has_permission(request, None))
def test_has_permission_with_view_kwargs_as_owner_with_get(self):
""" Owners always have permission to make GET actions. """
user = UserFactory()
self.request.user = user
view = GenericAPIView()
view.kwargs = {'username': user.username}
self.assertTrue(self.permission.has_permission(self.request, view))
@ddt.data('patch', 'post', 'put') @ddt.data('patch', 'post', 'put')
def test_has_permission_as_owner_with_edit(self, action): def test_has_permission_as_owner_with_edit(self, action):
""" Owners always have permission to edit. """ """ Owners always have permission to edit. """
user = UserFactory.create() user = UserFactory()
data = {'username': user.username} data = {'username': user.username}
request = getattr(RequestFactory(), action)('/', data, format='json') request = getattr(RequestFactory(), action)('/', data, format='json')
...@@ -172,7 +181,15 @@ class IsStaffOrOwnerTests(TestCase): ...@@ -172,7 +181,15 @@ class IsStaffOrOwnerTests(TestCase):
def test_has_permission_as_non_owner(self): def test_has_permission_as_non_owner(self):
""" Non-owners should not have permission. """ """ Non-owners should not have permission. """
user = UserFactory.create() user = UserFactory()
request = RequestFactory().get('/?username={}'.format(user.username)) request = RequestFactory().get('/?username={}'.format(user.username))
request.user = UserFactory.create() request.user = UserFactory()
self.assertFalse(self.permission.has_permission(request, None)) self.assertFalse(self.permission.has_permission(request, None))
def test_has_permission_with_view_kwargs_as_non_owner(self):
""" Non-owners should not have permission. """
user = UserFactory()
self.request.user = user
view = GenericAPIView()
view.kwargs = {'username': UserFactory().username}
self.assertFalse(self.permission.has_permission(self.request, view))
...@@ -154,7 +154,7 @@ chrono==1.0.2 ...@@ -154,7 +154,7 @@ chrono==1.0.2
ddt==0.8.0 ddt==0.8.0
django-crum==0.5 django-crum==0.5
django_nose==1.4.1 django_nose==1.4.1
factory_boy==2.5.1 factory_boy==2.8.1
flaky==3.3.0 flaky==3.3.0
freezegun==0.1.11 freezegun==0.1.11
mock-django==0.6.9 mock-django==0.6.9
......
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