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
from django.test.utils import override_settings
from django.conf import settings
from django.utils import translation
from django.utils.crypto import get_random_string
from nose.plugins.skip import SkipTest
......@@ -231,17 +230,6 @@ class TestDownloadYoutubeSubs(SharedModuleStoreTestCase):
self.assertEqual(html5_ids[2], 'baz.1.4')
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')
def test_fail_downloading_subs(self, mock_get):
......
......@@ -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() {
......
......@@ -110,7 +110,6 @@ define(['jquery', 'underscore', 'jquery.ajaxQueue'], function($) {
*/
var _videoLinkParser = (function() {
var cache = {};
var maxVideoNameLength = 150;
return function(url) {
if (typeof url !== 'string') {
......@@ -130,10 +129,7 @@ define(['jquery', 'underscore', 'jquery.ajaxQueue'], function($) {
match = link.pathname.match(/\/{1}([^\/]+)\.([^\/]+)$/);
if (match) {
cache[url] = {
/* avoid too long video name, as it will be used as filename for video's transcript
and a filename can not be more that 255 chars, limiting here to 150.
*/
video: match[1].slice(0, maxVideoNameLength),
video: match[1],
type: match[2]
};
} else {
......@@ -143,7 +139,7 @@ define(['jquery', 'underscore', 'jquery.ajaxQueue'], function($) {
match = link.pathname.match(/\/{1}([^\/\.]+)$/);
if (match) {
cache[url] = {
video: match[1].slice(0, maxVideoNameLength),
video: match[1],
type: 'other'
};
}
......
......@@ -144,7 +144,7 @@ from openedx.core.djangolib.markup import HTML, Text
<h5 class="title">${_("Location ID")}</h5>
<p class="unit-id">
<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>
</div>
<div class="wrapper-unit-tree-location bar-mod-content">
......
......@@ -11,8 +11,19 @@ from util import views
from zendesk import ZendeskError
import json
import mock
from ddt import ddt, data, unpack
from student.tests.test_configuration_overrides import fake_get_value
from student.tests.factories import CourseEnrollmentFactory
TEST_SUPPORT_EMAIL = "support@example.com"
TEST_ZENDESK_CUSTOM_FIELD_CONFIG = {"course_id": 1234, "enrollment_mode": 5678}
TEST_REQUEST_HEADERS = {
"HTTP_REFERER": "test_referer",
"HTTP_USER_AGENT": "test_user_agent",
"REMOTE_ADDR": "1.2.3.4",
"SERVER_NAME": "test_server",
}
def fake_support_backend_values(name, default=None): # pylint: disable=unused-argument
......@@ -21,13 +32,20 @@ def fake_support_backend_values(name, default=None): # pylint: disable=unused-a
"""
config_dict = {
"CONTACT_FORM_SUBMISSION_BACKEND": "email",
"email_from_address": "support_from@example.com",
"email_from_address": TEST_SUPPORT_EMAIL,
}
return config_dict[name]
@ddt
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_FEEDBACK_SUBMISSION": True})
@override_settings(ZENDESK_URL="dummy", ZENDESK_USER="dummy", ZENDESK_API_KEY="dummy")
@override_settings(
DEFAULT_FROM_EMAIL=TEST_SUPPORT_EMAIL,
ZENDESK_URL="dummy",
ZENDESK_USER="dummy",
ZENDESK_API_KEY="dummy",
ZENDESK_CUSTOM_FIELDS={}
)
@mock.patch("util.views.dog_stats_api")
@mock.patch("util.views._ZendeskApi", autospec=True)
class SubmitFeedbackTest(TestCase):
......@@ -44,14 +62,12 @@ class SubmitFeedbackTest(TestCase):
username="test",
profile__name="Test User"
)
# This contains issue_type and course_id to ensure that tags are submitted correctly
self._anon_fields = {
"email": "test@edx.org",
"name": "Test User",
"subject": "a subject",
"details": "some details",
"issue_type": "test_issue",
"course_id": "test_course"
"issue_type": "test_issue"
}
# This does not contain issue_type nor course_id to ensure that they are optional
self._auth_fields = {"subject": "a subject", "details": "some details"}
......@@ -66,10 +82,10 @@ class SubmitFeedbackTest(TestCase):
req = self._request_factory.post(
"/submit_feedback",
data=fields,
HTTP_REFERER="test_referer",
HTTP_USER_AGENT="test_user_agent",
REMOTE_ADDR="1.2.3.4",
SERVER_NAME="test_server",
HTTP_REFERER=TEST_REQUEST_HEADERS["HTTP_REFERER"],
HTTP_USER_AGENT=TEST_REQUEST_HEADERS["HTTP_USER_AGENT"],
REMOTE_ADDR=TEST_REQUEST_HEADERS["REMOTE_ADDR"],
SERVER_NAME=TEST_REQUEST_HEADERS["SERVER_NAME"],
)
req.user = user
return views.submit_feedback(req)
......@@ -130,13 +146,58 @@ class SubmitFeedbackTest(TestCase):
resp = self._build_and_run_request(user, fields)
self.assertEqual(resp.status_code, 200)
def _assert_datadog_called(self, datadog_mock, with_tags):
expected_datadog_calls = [
mock.call.increment(
views.DATADOG_FEEDBACK_METRIC,
tags=(["course_id:test_course", "issue_type:test_issue"] if with_tags else [])
)
def _build_zendesk_ticket(self, recipient, name, email, subject, details, tags, custom_fields=None):
"""
Build a Zendesk ticket that can be used in assertions to verify that the correct
data was submitted to create a Zendesk ticket.
"""
ticket = {
"ticket": {
"recipient": recipient,
"requester": {"name": name, "email": email},
"subject": subject,
"comment": {"body": details},
"tags": tags
}
}
if custom_fields is not None:
ticket["ticket"]["custom_fields"] = custom_fields
return ticket
def _build_zendesk_ticket_update(self, request_headers, username=None):
"""
Build a Zendesk ticket update that can be used in assertions to verify that the correct
data was submitted to update a Zendesk ticket.
"""
body = []
if username:
body.append("username: {}".format(username))
# FIXME the tests rely on the body string being built in this specific order, which doesn't seem
# reliable given that the view builds the string by iterating over a dictionary.
header_text_mapping = [
("Client IP", "REMOTE_ADDR"),
("Host", "SERVER_NAME"),
("Page", "HTTP_REFERER"),
("Browser", "HTTP_USER_AGENT")
]
for text, header in header_text_mapping:
body.append("{}: {}".format(text, request_headers[header]))
body = "Additional information:\n\n" + "\n".join(body)
return {"ticket": {"comment": {"public": False, "body": body}}}
def _assert_zendesk_called(self, zendesk_mock, ticket_id, ticket, ticket_update):
"""Assert that Zendesk was called with the correct ticket and ticket_update."""
expected_zendesk_calls = [mock.call.create_ticket(ticket), mock.call.update_ticket(ticket_id, ticket_update)]
self.assertEqual(zendesk_mock.mock_calls, expected_zendesk_calls)
def _assert_datadog_called(self, datadog_mock, tags):
"""Assert that datadog was called with the correct tags."""
expected_datadog_calls = [mock.call.increment(views.DATADOG_FEEDBACK_METRIC, tags=tags)]
self.assertEqual(datadog_mock.mock_calls, expected_datadog_calls)
def test_bad_request_anon_user_no_name(self, zendesk_mock_class, datadog_mock):
......@@ -174,39 +235,26 @@ class SubmitFeedbackTest(TestCase):
the given information should have been submitted via the Zendesk API.
"""
zendesk_mock_instance = zendesk_mock_class.return_value
zendesk_mock_instance.create_ticket.return_value = 42
self._test_success(self._anon_user, self._anon_fields)
expected_zendesk_calls = [
mock.call.create_ticket(
{
"ticket": {
"recipient": "registration@example.com",
"requester": {"name": "Test User", "email": "test@edx.org"},
"subject": "a subject",
"comment": {"body": "some details"},
"tags": ["test_course", "test_issue", "LMS"]
}
}
),
mock.call.update_ticket(
42,
{
"ticket": {
"comment": {
"public": False,
"body":
"Additional information:\n\n"
"Client IP: 1.2.3.4\n"
"Host: test_server\n"
"Page: test_referer\n"
"Browser: test_user_agent"
}
}
}
user = self._anon_user
fields = self._anon_fields
ticket_id = 42
zendesk_mock_instance.create_ticket.return_value = ticket_id
ticket = self._build_zendesk_ticket(
recipient=TEST_SUPPORT_EMAIL,
name=fields["name"],
email=fields["email"],
subject=fields["subject"],
details=fields["details"],
tags=[fields["issue_type"], "LMS"]
)
]
self.assertEqual(zendesk_mock_instance.mock_calls, expected_zendesk_calls)
self._assert_datadog_called(datadog_mock, with_tags=True)
ticket_update = self._build_zendesk_ticket_update(TEST_REQUEST_HEADERS)
self._test_success(user, fields)
self._assert_zendesk_called(zendesk_mock_instance, ticket_id, ticket, ticket_update)
self._assert_datadog_called(datadog_mock, ["issue_type:{}".format(fields["issue_type"])])
@mock.patch("openedx.core.djangoapps.site_configuration.helpers.get_value", fake_get_value)
def test_valid_request_anon_user_configuration_override(self, zendesk_mock_class, datadog_mock):
......@@ -218,39 +266,75 @@ class SubmitFeedbackTest(TestCase):
tag that will come from site configuration override.
"""
zendesk_mock_instance = zendesk_mock_class.return_value
zendesk_mock_instance.create_ticket.return_value = 42
self._test_success(self._anon_user, self._anon_fields)
expected_zendesk_calls = [
mock.call.create_ticket(
{
"ticket": {
"recipient": "no-reply@fakeuniversity.com",
"requester": {"name": "Test User", "email": "test@edx.org"},
"subject": "a subject",
"comment": {"body": "some details"},
"tags": ["test_course", "test_issue", "LMS", "whitelabel_fakeorg"]
}
}
),
mock.call.update_ticket(
42,
{
"ticket": {
"comment": {
"public": False,
"body":
"Additional information:\n\n"
"Client IP: 1.2.3.4\n"
"Host: test_server\n"
"Page: test_referer\n"
"Browser: test_user_agent"
}
}
}
user = self._anon_user
fields = self._anon_fields
ticket_id = 42
zendesk_mock_instance.create_ticket.return_value = ticket_id
ticket = self._build_zendesk_ticket(
recipient=fake_get_value("email_from_address"),
name=fields["name"],
email=fields["email"],
subject=fields["subject"],
details=fields["details"],
tags=[fields["issue_type"], "LMS", "whitelabel_{}".format(fake_get_value("course_org_filter"))]
)
ticket_update = self._build_zendesk_ticket_update(TEST_REQUEST_HEADERS)
self._test_success(user, fields)
self._assert_zendesk_called(zendesk_mock_instance, ticket_id, ticket, ticket_update)
self._assert_datadog_called(datadog_mock, ["issue_type:{}".format(fields["issue_type"])])
@data("course-v1:testOrg+testCourseNumber+testCourseRun", "", None)
@override_settings(ZENDESK_CUSTOM_FIELDS=TEST_ZENDESK_CUSTOM_FIELD_CONFIG)
def test_valid_request_anon_user_with_custom_fields(self, course_id, zendesk_mock_class, datadog_mock):
"""
Test a valid request from an anonymous user when configured to use Zendesk Custom Fields.
The response should have a 200 (success) status code, and a ticket with
the given information should have been submitted via the Zendesk API. When course_id is
present, it should be sent to Zendesk via a custom field. When course_id is blank or missing,
the request should still be processed successfully.
"""
zendesk_mock_instance = zendesk_mock_class.return_value
user = self._anon_user
fields = self._anon_fields.copy()
if course_id is not None:
fields["course_id"] = course_id
ticket_id = 42
zendesk_mock_instance.create_ticket.return_value = ticket_id
zendesk_tags = [fields["issue_type"], "LMS"]
datadog_tags = ["issue_type:{}".format(fields["issue_type"])]
zendesk_custom_fields = None
if course_id:
# FIXME the tests rely on the tags being in this specific order, which doesn't seem
# reliable given that the view builds the list by iterating over a dictionary.
zendesk_tags.insert(0, course_id)
datadog_tags.insert(0, "course_id:{}".format(course_id))
zendesk_custom_fields = [
{"id": TEST_ZENDESK_CUSTOM_FIELD_CONFIG["course_id"], "value": course_id}
]
self.assertEqual(zendesk_mock_instance.mock_calls, expected_zendesk_calls)
self._assert_datadog_called(datadog_mock, with_tags=True)
ticket = self._build_zendesk_ticket(
recipient=TEST_SUPPORT_EMAIL,
name=fields["name"],
email=fields["email"],
subject=fields["subject"],
details=fields["details"],
tags=zendesk_tags,
custom_fields=zendesk_custom_fields
)
ticket_update = self._build_zendesk_ticket_update(TEST_REQUEST_HEADERS)
self._test_success(user, fields)
self._assert_zendesk_called(zendesk_mock_instance, ticket_id, ticket, ticket_update)
self._assert_datadog_called(datadog_mock, datadog_tags)
def test_bad_request_auth_user_no_subject(self, zendesk_mock_class, datadog_mock):
"""Test a request from an authenticated user not specifying `subject`."""
......@@ -270,40 +354,92 @@ class SubmitFeedbackTest(TestCase):
the given information should have been submitted via the Zendesk API.
"""
zendesk_mock_instance = zendesk_mock_class.return_value
zendesk_mock_instance.create_ticket.return_value = 42
self._test_success(self._auth_user, self._auth_fields)
expected_zendesk_calls = [
mock.call.create_ticket(
{
"ticket": {
"recipient": "registration@example.com",
"requester": {"name": "Test User", "email": "test@edx.org"},
"subject": "a subject",
"comment": {"body": "some details"},
"tags": ["LMS"]
}
}
),
mock.call.update_ticket(
42,
{
"ticket": {
"comment": {
"public": False,
"body":
"Additional information:\n\n"
"username: test\n"
"Client IP: 1.2.3.4\n"
"Host: test_server\n"
"Page: test_referer\n"
"Browser: test_user_agent"
}
}
}
user = self._auth_user
fields = self._auth_fields
ticket_id = 42
zendesk_mock_instance.create_ticket.return_value = ticket_id
ticket = self._build_zendesk_ticket(
recipient=TEST_SUPPORT_EMAIL,
name=user.profile.name,
email=user.email,
subject=fields["subject"],
details=fields["details"],
tags=["LMS"]
)
ticket_update = self._build_zendesk_ticket_update(TEST_REQUEST_HEADERS, user.username)
self._test_success(user, fields)
self._assert_zendesk_called(zendesk_mock_instance, ticket_id, ticket, ticket_update)
self._assert_datadog_called(datadog_mock, [])
@data(
("course-v1:testOrg+testCourseNumber+testCourseRun", True),
("course-v1:testOrg+testCourseNumber+testCourseRun", False),
("", None),
(None, None)
)
@unpack
@override_settings(ZENDESK_CUSTOM_FIELDS=TEST_ZENDESK_CUSTOM_FIELD_CONFIG)
def test_valid_request_auth_user_with_custom_fields(self, course_id, enrolled, zendesk_mock_class, datadog_mock):
"""
Test a valid request from an authenticated user when configured to use Zendesk Custom Fields.
The response should have a 200 (success) status code, and a ticket with
the given information should have been submitted via the Zendesk API. When course_id is
present, it should be sent to Zendesk via a custom field, along with the enrollment mode
if the user has an active enrollment for that course. When course_id is blank or missing,
the request should still be processed successfully.
"""
zendesk_mock_instance = zendesk_mock_class.return_value
user = self._auth_user
fields = self._auth_fields.copy()
if course_id is not None:
fields["course_id"] = course_id
ticket_id = 42
zendesk_mock_instance.create_ticket.return_value = ticket_id
zendesk_tags = ["LMS"]
datadog_tags = []
zendesk_custom_fields = None
if course_id:
# FIXME the tests rely on the tags being in this specific order, which doesn't seem
# reliable given that the view builds the list by iterating over a dictionary.
zendesk_tags.insert(0, course_id)
datadog_tags.insert(0, "course_id:{}".format(course_id))
zendesk_custom_fields = [
{"id": TEST_ZENDESK_CUSTOM_FIELD_CONFIG["course_id"], "value": course_id}
]
self.assertEqual(zendesk_mock_instance.mock_calls, expected_zendesk_calls)
self._assert_datadog_called(datadog_mock, with_tags=False)
if enrolled is not None:
enrollment = CourseEnrollmentFactory.create(
user=user,
course_id=course_id,
is_active=enrolled
)
if enrollment.is_active:
zendesk_custom_fields.append(
{"id": TEST_ZENDESK_CUSTOM_FIELD_CONFIG["enrollment_mode"], "value": enrollment.mode}
)
ticket = self._build_zendesk_ticket(
recipient=TEST_SUPPORT_EMAIL,
name=user.profile.name,
email=user.email,
subject=fields["subject"],
details=fields["details"],
tags=zendesk_tags,
custom_fields=zendesk_custom_fields
)
ticket_update = self._build_zendesk_ticket_update(TEST_REQUEST_HEADERS, user.username)
self._test_success(user, fields)
self._assert_zendesk_called(zendesk_mock_instance, ticket_id, ticket, ticket_update)
self._assert_datadog_called(datadog_mock, datadog_tags)
def test_get_request(self, zendesk_mock_class, datadog_mock):
"""Test that a GET results in a 405 even with all required fields"""
......@@ -329,7 +465,7 @@ class SubmitFeedbackTest(TestCase):
resp = self._build_and_run_request(self._anon_user, self._anon_fields)
self.assertEqual(resp.status_code, 500)
self.assertFalse(resp.content)
self._assert_datadog_called(datadog_mock, with_tags=True)
self._assert_datadog_called(datadog_mock, ["issue_type:{}".format(self._anon_fields["issue_type"])])
def test_zendesk_error_on_update(self, zendesk_mock_class, datadog_mock):
"""
......@@ -344,7 +480,7 @@ class SubmitFeedbackTest(TestCase):
zendesk_mock_instance.update_ticket.side_effect = err
resp = self._build_and_run_request(self._anon_user, self._anon_fields)
self.assertEqual(resp.status_code, 200)
self._assert_datadog_called(datadog_mock, with_tags=True)
self._assert_datadog_called(datadog_mock, ["issue_type:{}".format(self._anon_fields["issue_type"])])
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_FEEDBACK_SUBMISSION": False})
def test_not_enabled(self, zendesk_mock_class, datadog_mock):
......
......@@ -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
import track.views
from student.roles import GlobalStaff
from student.models import CourseEnrollment
log = logging.getLogger(__name__)
......@@ -222,6 +223,40 @@ class _ZendeskApi(object):
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(
realname,
email,
......@@ -231,7 +266,8 @@ def _record_feedback_in_zendesk(
additional_info,
group_name=None,
require_update=False,
support_email=None
support_email=None,
custom_fields=None
):
"""
Create a new user-requested Zendesk ticket.
......@@ -246,6 +282,8 @@ def _record_feedback_in_zendesk(
If `require_update` is provided, returns False when the update does not
succeed. This allows using the private comment to add necessary information
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()
......@@ -271,6 +309,10 @@ def _record_feedback_in_zendesk(
"tags": zendesk_tags
}
}
if custom_fields:
new_ticket["ticket"]["custom_fields"] = custom_fields
group = None
if group_name is not None:
group = zendesk_api.get_group(group_name)
......@@ -322,7 +364,7 @@ def get_feedback_form_context(request):
context["subject"] = request.POST["subject"]
context["details"] = request.POST["details"]
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"] = {}
......@@ -412,6 +454,11 @@ def submit_feedback(request):
if not settings.ZENDESK_URL or not settings.ZENDESK_USER or not settings.ZENDESK_API_KEY:
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(
context["realname"],
context["email"],
......@@ -419,7 +466,8 @@ def submit_feedback(request):
context["details"],
context["tags"],
context["additional_info"],
support_email=context["support_email"]
support_email=context["support_email"],
custom_fields=custom_fields
)
_record_feedback_in_datadog(context["tags"])
......
......@@ -328,9 +328,16 @@ class InputTypeBase(object):
}
# 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
status_id = 'status_' + self.input_id
descriptions = list([status_id])
descriptions.append(status_id)
descriptions.extend(self.response_data.get('descriptions', {}).keys())
description_ids = ' '.join(descriptions)
context.update(
......
......@@ -16,7 +16,7 @@
size="${size}"
% 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"/>
......
......@@ -34,7 +34,7 @@
style="display:none;"
% 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"/>
......
......@@ -37,6 +37,8 @@ lookup_tag = inputtypes.registry.get_class_for_tag
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')])
RESPONSE_DATA = {
'label': 'question text 101',
......@@ -361,7 +363,7 @@ class TextLineTest(unittest.TestCase):
'trailing_text': expected_text,
'preprocessor': None,
'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)
......@@ -1295,7 +1297,7 @@ class FormulaEquationTest(unittest.TestCase):
'inline': False,
'trailing_text': expected_text,
'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)
......
......@@ -13,7 +13,7 @@ from contextlib import contextmanager
from uuid import uuid4
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 nose.tools import assert_less_equal, assert_greater_equal
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
def get_html5_ids(html5_sources):
"""
Helper method to parse out an HTML5 source into the ideas
NOTE: This assumes that '/' are not in the filename.
Slices each id by 150, restricting too long strings as video names.
NOTE: This assumes that '/' are not in the filename
"""
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
......
......@@ -93,6 +93,7 @@
this.courseSettings = options.courseSettings;
this.hideRefineBar = options.hideRefineBar;
this.supportsActiveThread = options.supportsActiveThread;
this.hideReadState = options.hideReadState || false;
this.displayedCollection = new Discussion(this.collection.models, {
pages: this.collection.pages
});
......@@ -342,7 +343,8 @@
neverRead: neverRead,
threadUrl: thread.urlFor('retrieve'),
threadPreview: threadPreview,
showThreadPreview: this.showThreadPreview
showThreadPreview: this.showThreadPreview,
hideReadState: this.hideReadState
},
thread.toJSON()
);
......
......@@ -169,6 +169,7 @@
});
return this.view.render();
});
setupAjax = function(callback) {
return $.ajax.and.callFake(function(params) {
if (callback) {
......@@ -185,19 +186,27 @@
};
});
};
renderSingleThreadWithProps = function(props) {
return makeView(new Discussion([new Thread(DiscussionViewSpecHelper.makeThreadWithProps(props))])).render();
};
makeView = function(discussion) {
return new DiscussionThreadListView({
makeView = function(discussion, props) {
return new DiscussionThreadListView(
_.extend(
{
el: $('#fixture-element'),
collection: discussion,
showThreadPreview: true,
courseSettings: new DiscussionCourseSettings({
is_cohorted: true
})
});
},
props
)
);
};
expectFilter = function(filterVal) {
return $.ajax.and.callFake(function(params) {
_.each(['unread', 'unanswered', 'flagged'], function(paramName) {
......@@ -681,5 +690,45 @@
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);
<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">
<div class="forum-nav-thread-wrapper-0">
<%
......@@ -75,7 +75,7 @@
%>
</span>
<% if (!neverRead && unread_comments_count > 0) { %>
<% if (!hideReadState && !neverRead && unread_comments_count > 0) { %>
<span class="forum-nav-thread-unread-comments-count">
<%-
StringUtils.interpolate(
......
......@@ -69,26 +69,13 @@ def is_youtube_available():
bool:
"""
youtube_api_urls = {
'main': 'https://www.youtube.com/',
'player': 'https://www.youtube.com/iframe_api',
# For transcripts, you need to check an actual video, so we will
# just specify our default video and see if that one is available.
'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:
# Skip all the youtube tests for now because they are failing intermittently
# due to changes on their side. See: TE-1927
# TODO: Design and implement a better solution that is reliable and repeatable,
# reflects how the application works in production, and limits the third-party
# network traffic (e.g. repeatedly retrieving the js from youtube from the browser).
return False
if response.status_code >= 300:
return False
return True
def is_focused_on_element(browser, selector):
"""
......
......@@ -39,7 +39,10 @@
collection: this.discussion,
el: this.$('.inline-threads'),
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();
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):
)
)
# 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):
return u"Email marketing configuration: New user list %s, Activation template: %s" % \
(self.sailthru_new_user_list, self.sailthru_activation_template)
......@@ -3,6 +3,7 @@ This file contains celery tasks for email marketing signal handler.
"""
import logging
import time
from datetime import datetime, timedelta
from celery import task
from django.core.cache import cache
......@@ -56,10 +57,16 @@ def update_user(self, sailthru_vars, email, site=None, new_user=False, activatio
# if activating user, send welcome email
if activation and email_config.sailthru_activation_template:
scheduled_datetime = datetime.utcnow() + timedelta(seconds=email_config.welcome_email_send_delay)
try:
sailthru_response = sailthru_client.api_post("send",
{"email": email,
"template": email_config.sailthru_activation_template})
sailthru_response = sailthru_client.api_post(
"send",
{
"email": email,
"template": email_config.sailthru_activation_template,
"schedule_time": scheduled_datetime.strftime('%Y-%m-%dT%H:%M:%SZ')
}
)
except SailthruClientError as exc:
log.error("Exception attempting to send welcome email to user %s in Sailthru - %s", email, unicode(exc))
raise self.retry(exc=exc,
......
"""Tests of email marketing signal handlers."""
import ddt
import logging
import datetime
from django.test import TestCase
from django.contrib.auth.models import AnonymousUser
......@@ -45,6 +46,7 @@ def update_email_marketing_config(enabled=True, key='badkey', secret='badsecret'
sailthru_get_tags_from_sailthru=False,
sailthru_enroll_cost=enroll_cost,
sailthru_max_retries=0,
welcome_email_send_delay=600
)
......@@ -168,12 +170,14 @@ class EmailMarketingTests(TestCase):
"""
mock_sailthru_post.return_value = SailthruResponse(JsonResponse({'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)
# look for call args for 2nd call
self.assertEquals(mock_sailthru_post.call_args[0][0], "send")
userparms = mock_sailthru_post.call_args[0][1]
self.assertEquals(userparms['email'], TEST_EMAIL)
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.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
"""
Tests for reset_grades management command.
"""
from datetime import datetime, timedelta
import ddt
from django.core.management.base import CommandError
from django.test import TestCase
from freezegun import freeze_time
from mock import patch, MagicMock
from lms.djangoapps.grades.management.commands import reset_grades
from lms.djangoapps.grades.models import PersistentSubsectionGrade, PersistentCourseGrade
from opaque_keys.edx.locator import CourseLocator, BlockUsageLocator
@ddt.ddt
class TestResetGrades(TestCase):
"""
Tests generate course blocks management command.
"""
num_users = 3
num_courses = 5
num_subsections = 7
def setUp(self):
super(TestResetGrades, self).setUp()
self.command = reset_grades.Command()
self.user_ids = [user_id for user_id in range(self.num_users)]
self.course_keys = []
for course_index in range(self.num_courses):
self.course_keys.append(
CourseLocator(
org='some_org',
course='some_course',
run=unicode(course_index),
)
)
self.subsection_keys_by_course = {}
for course_key in self.course_keys:
subsection_keys_in_course = []
for subsection_index in range(self.num_subsections):
subsection_keys_in_course.append(
BlockUsageLocator(
course_key=course_key,
block_type='sequential',
block_id=unicode(subsection_index),
)
)
self.subsection_keys_by_course[course_key] = subsection_keys_in_course
def _update_or_create_grades(self, courses_keys=None):
"""
Creates grades for all courses and subsections.
"""
if courses_keys is None:
courses_keys = self.course_keys
course_grade_params = {
"course_version": "JoeMcEwing",
"course_edited_timestamp": datetime(
year=2016,
month=8,
day=1,
hour=18,
minute=53,
second=24,
microsecond=354741,
),
"percent_grade": 77.7,
"letter_grade": "Great job",
"passed": True,
}
subsection_grade_params = {
"course_version": "deadbeef",
"subtree_edited_timestamp": "2016-08-01 18:53:24.354741",
"earned_all": 6.0,
"possible_all": 12.0,
"earned_graded": 6.0,
"possible_graded": 8.0,
"visible_blocks": MagicMock(),
"attempted": True,
}
for course_key in courses_keys:
for user_id in self.user_ids:
course_grade_params['user_id'] = user_id
course_grade_params['course_id'] = course_key
PersistentCourseGrade.update_or_create_course_grade(**course_grade_params)
for subsection_key in self.subsection_keys_by_course[course_key]:
subsection_grade_params['user_id'] = user_id
subsection_grade_params['usage_key'] = subsection_key
PersistentSubsectionGrade.update_or_create_grade(**subsection_grade_params)
def _assert_grades_exist_for_courses(self, course_keys):
"""
Assert grades for given courses exist.
"""
for course_key in course_keys:
self.assertIsNotNone(PersistentCourseGrade.read_course_grade(self.user_ids[0], course_key))
for subsection_key in self.subsection_keys_by_course[course_key]:
self.assertIsNotNone(PersistentSubsectionGrade.read_grade(self.user_ids[0], subsection_key))
def _assert_grades_absent_for_courses(self, course_keys):
"""
Assert grades for given courses do not exist.
"""
for course_key in course_keys:
with self.assertRaises(PersistentCourseGrade.DoesNotExist):
PersistentCourseGrade.read_course_grade(self.user_ids[0], course_key)
for subsection_key in self.subsection_keys_by_course[course_key]:
with self.assertRaises(PersistentSubsectionGrade.DoesNotExist):
PersistentSubsectionGrade.read_grade(self.user_ids[0], subsection_key)
def _assert_stat_logged(self, mock_log, num_rows, grade_model_class, message_substring, log_offset):
self.assertIn('reset_grade: ' + message_substring, mock_log.info.call_args_list[log_offset][0][0])
self.assertEqual(grade_model_class.__name__, mock_log.info.call_args_list[log_offset][0][1])
self.assertEqual(num_rows, mock_log.info.call_args_list[log_offset][0][2])
def _assert_course_delete_stat_logged(self, mock_log, num_rows):
self._assert_stat_logged(mock_log, num_rows, PersistentCourseGrade, 'Deleted', log_offset=4)
def _assert_subsection_delete_stat_logged(self, mock_log, num_rows):
self._assert_stat_logged(mock_log, num_rows, PersistentSubsectionGrade, 'Deleted', log_offset=2)
def _assert_course_query_stat_logged(self, mock_log, num_rows, num_courses=None):
if num_courses is None:
num_courses = self.num_courses
log_offset = num_courses + 1 + num_courses + 1
self._assert_stat_logged(mock_log, num_rows, PersistentCourseGrade, 'Would delete', log_offset)
def _assert_subsection_query_stat_logged(self, mock_log, num_rows, num_courses=None):
if num_courses is None:
num_courses = self.num_courses
log_offset = num_courses + 1
self._assert_stat_logged(mock_log, num_rows, PersistentSubsectionGrade, 'Would delete', log_offset)
def _date_from_now(self, days=None):
return datetime.now() + timedelta(days=days)
def _date_str_from_now(self, days=None):
future_date = self._date_from_now(days=days)
return future_date.strftime(reset_grades.DATE_FORMAT)
@patch('lms.djangoapps.grades.management.commands.reset_grades.log')
def test_reset_all_courses(self, mock_log):
self._update_or_create_grades()
self._assert_grades_exist_for_courses(self.course_keys)
with self.assertNumQueries(4):
self.command.handle(delete=True, all_courses=True)
self._assert_grades_absent_for_courses(self.course_keys)
self._assert_subsection_delete_stat_logged(
mock_log,
num_rows=self.num_users * self.num_courses * self.num_subsections,
)
self._assert_course_delete_stat_logged(
mock_log,
num_rows=self.num_users * self.num_courses,
)
@patch('lms.djangoapps.grades.management.commands.reset_grades.log')
@ddt.data(1, 2, 3)
def test_reset_some_courses(self, num_courses_to_reset, mock_log):
self._update_or_create_grades()
self._assert_grades_exist_for_courses(self.course_keys)
with self.assertNumQueries(4):
self.command.handle(
delete=True,
courses=[unicode(course_key) for course_key in self.course_keys[:num_courses_to_reset]]
)
self._assert_grades_absent_for_courses(self.course_keys[:num_courses_to_reset])
self._assert_grades_exist_for_courses(self.course_keys[num_courses_to_reset:])
self._assert_subsection_delete_stat_logged(
mock_log,
num_rows=self.num_users * num_courses_to_reset * self.num_subsections,
)
self._assert_course_delete_stat_logged(
mock_log,
num_rows=self.num_users * num_courses_to_reset,
)
def test_reset_by_modified_start_date(self):
self._update_or_create_grades()
self._assert_grades_exist_for_courses(self.course_keys)
num_courses_with_updated_grades = 2
with freeze_time(self._date_from_now(days=4)):
self._update_or_create_grades(self.course_keys[:num_courses_with_updated_grades])
with self.assertNumQueries(4):
self.command.handle(delete=True, modified_start=self._date_str_from_now(days=2), all_courses=True)
self._assert_grades_absent_for_courses(self.course_keys[:num_courses_with_updated_grades])
self._assert_grades_exist_for_courses(self.course_keys[num_courses_with_updated_grades:])
def test_reset_by_modified_start_end_date(self):
self._update_or_create_grades()
self._assert_grades_exist_for_courses(self.course_keys)
with freeze_time(self._date_from_now(days=3)):
self._update_or_create_grades(self.course_keys[:2])
with freeze_time(self._date_from_now(days=5)):
self._update_or_create_grades(self.course_keys[2:4])
with self.assertNumQueries(4):
self.command.handle(
delete=True,
modified_start=self._date_str_from_now(days=2),
modified_end=self._date_str_from_now(days=4),
all_courses=True,
)
# Only grades for courses modified within the 2->4 days
# should be deleted.
self._assert_grades_absent_for_courses(self.course_keys[:2])
self._assert_grades_exist_for_courses(self.course_keys[2:])
@patch('lms.djangoapps.grades.management.commands.reset_grades.log')
def test_dry_run_all_courses(self, mock_log):
self._update_or_create_grades()
self._assert_grades_exist_for_courses(self.course_keys)
with self.assertNumQueries(2):
self.command.handle(dry_run=True, all_courses=True)
self._assert_grades_exist_for_courses(self.course_keys)
self._assert_subsection_query_stat_logged(
mock_log,
num_rows=self.num_users * self.num_courses * self.num_subsections,
)
self._assert_course_query_stat_logged(
mock_log,
num_rows=self.num_users * self.num_courses,
)
@patch('lms.djangoapps.grades.management.commands.reset_grades.log')
@ddt.data(1, 2, 3)
def test_dry_run_some_courses(self, num_courses_to_query, mock_log):
self._update_or_create_grades()
self._assert_grades_exist_for_courses(self.course_keys)
with self.assertNumQueries(2):
self.command.handle(
dry_run=True,
courses=[unicode(course_key) for course_key in self.course_keys[:num_courses_to_query]]
)
self._assert_grades_exist_for_courses(self.course_keys)
self._assert_subsection_query_stat_logged(
mock_log,
num_rows=self.num_users * num_courses_to_query * self.num_subsections,
num_courses=num_courses_to_query,
)
self._assert_course_query_stat_logged(
mock_log,
num_rows=self.num_users * num_courses_to_query,
num_courses=num_courses_to_query,
)
@patch('lms.djangoapps.grades.management.commands.reset_grades.log')
def test_reset_no_existing_grades(self, mock_log):
self._assert_grades_absent_for_courses(self.course_keys)
with self.assertNumQueries(4):
self.command.handle(delete=True, all_courses=True)
self._assert_grades_absent_for_courses(self.course_keys)
self._assert_subsection_delete_stat_logged(mock_log, num_rows=0)
self._assert_course_delete_stat_logged(mock_log, num_rows=0)
def test_invalid_key(self):
with self.assertRaisesRegexp(CommandError, 'Invalid key specified.*invalid/key'):
self.command.handle(dry_run=True, courses=['invalid/key'])
def test_no_run_mode(self):
with self.assertRaisesMessage(CommandError, 'Either --delete or --dry_run must be specified.'):
self.command.handle(all_courses=True)
def test_both_run_modes(self):
with self.assertRaisesMessage(CommandError, 'Both --delete and --dry_run cannot be specified.'):
self.command.handle(all_courses=True, dry_run=True, delete=True)
def test_no_course_mode(self):
with self.assertRaisesMessage(CommandError, 'Either --courses or --all_courses must be specified.'):
self.command.handle(dry_run=True)
def test_both_course_modes(self):
with self.assertRaisesMessage(CommandError, 'Both --courses and --all_courses cannot be specified.'):
self.command.handle(dry_run=True, all_courses=True, courses=['some/course/key'])
......@@ -38,6 +38,38 @@ BLOCK_RECORD_LIST_VERSION = 1
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):
"""
An immutable ordered list of BlockRecord objects.
......@@ -208,7 +240,7 @@ class VisibleBlocks(models.Model):
cls.bulk_create(non_existent_brls)
class PersistentSubsectionGrade(TimeStampedModel):
class PersistentSubsectionGrade(DeleteGradesMixin, TimeStampedModel):
"""
A django model tracking persistent grades at the subsection level.
"""
......@@ -458,7 +490,7 @@ class PersistentSubsectionGrade(TimeStampedModel):
)
class PersistentCourseGrade(TimeStampedModel):
class PersistentCourseGrade(DeleteGradesMixin, TimeStampedModel):
"""
A django model tracking persistent course grades.
"""
......
......@@ -12,6 +12,7 @@ from lazy import lazy
from lms.djangoapps.course_blocks.api import get_course_blocks
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 xmodule import block_metadata_utils
......@@ -323,17 +324,23 @@ class CourseGradeFactory(object):
"""
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.
If read_only is True, doesn't save any updates to the grades.
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 not self._user_has_access_to_course(course_structure):
raise PermissionDenied("User does not have access to this course")
return (
self._get_saved_grade(student, course, course_structure) or
self._compute_and_update_grade(student, course, course_structure, read_only)
......@@ -351,11 +358,17 @@ class CourseGradeFactory(object):
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.
"""
# 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:
with dog_stats_api.timer('lms.grades.CourseGradeFactory.iter', tags=[u'action:{}'.format(course.id)]):
try:
course_grade = CourseGradeFactory().create(student, course)
course_grade = CourseGradeFactory().create(student, course, collected_block_structure)
yield self.GradeResult(student, course_grade, "")
except Exception as exc: # pylint: disable=broad-except
......
......@@ -12,6 +12,7 @@ from courseware.model_data import set_score
from courseware.tests.helpers import LoginEnrollmentTestCase
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 student.tests.factories import UserFactory
from student.models import CourseEnrollment
......@@ -68,7 +69,14 @@ class TestGradeIteration(SharedModuleStoreTestCase):
"""
No students have grade entries.
"""
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)
for course_grade in all_course_grades.values():
self.assertIsNone(course_grade.letter_grade)
......
......@@ -333,6 +333,8 @@ COMMENTS_SERVICE_URL = ENV_TOKENS.get("COMMENTS_SERVICE_URL", '')
COMMENTS_SERVICE_KEY = ENV_TOKENS.get("COMMENTS_SERVICE_KEY", '')
CERT_QUEUE = ENV_TOKENS.get("CERT_QUEUE", 'test-pull')
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")
MKTG_URLS = ENV_TOKENS.get('MKTG_URLS', MKTG_URLS)
......
......@@ -76,6 +76,17 @@ PIPELINE_JS_COMPRESSOR = None
CELERY_ALWAYS_EAGER = True
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 ######################
GRADES_DOWNLOAD = {
'STORAGE_TYPE': 'localfs',
......
......@@ -1003,6 +1003,7 @@ FEEDBACK_SUBMISSION_EMAIL = None
ZENDESK_URL = None
ZENDESK_USER = None
ZENDESK_API_KEY = None
ZENDESK_CUSTOM_FIELDS = {}
##### EMBARGO #####
EMBARGO_SITE_REDIRECT_URL = None
......
......@@ -35,6 +35,7 @@
@import 'shared/modal';
@import 'shared/activation_messages';
@import 'shared/unsubscribe';
@import 'shared/help-tab';
// shared - platform
@import 'multicourse/home';
......
......@@ -78,3 +78,9 @@
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
<label data-field="email" for="feedback_form_email">${_('E-mail')}*</label>
<input name="email" type="text" id="feedback_form_email" required>
% 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>
<input name="subject" type="text" id="feedback_form_subject" required>
<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>
<textarea name="details" id="feedback_form_details" required aria-describedby="feedback_form_details_tip"></textarea>
<input name="issue_type" type="hidden">
% if course:
<input name="course_id" type="hidden" value="${unicode(course.id)}">
% endif
<div class="submit">
<input name="submit" type="submit" value="${_('Submit')}" id="feedback_submit">
</div>
......@@ -138,7 +144,12 @@ from xmodule.tabs import CourseTabList
<script type="text/javascript">
$(document).ready(function() {
var $helpModal = $("#help-modal"),
var currentCourseId,
courseOptions = [],
userAuthenticated = false,
courseOptionsLoadInProgress = false,
finishedLoadingCourseOptions = false,
$helpModal = $("#help-modal"),
$closeButton = $("#help-modal .close-modal"),
$leanOverlay = $("#lean_overlay"),
$feedbackForm = $("#feedback_form"),
......@@ -149,11 +160,101 @@ $(document).ready(function() {
$('area,input,select,textarea,button').removeAttr('tabindex');
$(".help-tab a").focus();
$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);
$(".help-tab").click(function() {
if (userAuthenticated && !finishedLoadingCourseOptions && !courseOptionsLoadInProgress) {
loadCourseOptions();
}
$helpModal.css("position", "absolute");
DialogTabControls.initializeTabKeyValues("#help_wrapper", $closeButton);
$(".field-error").removeClass("field-error");
......@@ -171,18 +272,6 @@ $(document).ready(function() {
$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_form_details_tip").css({"display": "block", "padding-bottom": "5px"});
showFeedback(
......
......@@ -26,8 +26,8 @@ class PhotoVerificationStatusViewTests(TestCase):
def setUp(self):
super(PhotoVerificationStatusViewTests, self).setUp()
self.user = UserFactory.create(password=self.PASSWORD)
self.staff = UserFactory.create(is_staff=True, password=self.PASSWORD)
self.user = UserFactory(password=self.PASSWORD)
self.staff = UserFactory(is_staff=True, password=self.PASSWORD)
self.verification = SoftwareSecurePhotoVerification.objects.create(user=self.user, status='submitted')
self.path = reverse('verification_status', kwargs={'username': self.user.username})
self.client.login(username=self.staff.username, password=self.PASSWORD)
......@@ -57,7 +57,7 @@ class PhotoVerificationStatusViewTests(TestCase):
def test_no_verifications(self):
""" 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})
self.assert_path_not_found(path)
......@@ -69,17 +69,19 @@ class PhotoVerificationStatusViewTests(TestCase):
def test_staff_user(self):
""" The endpoint should be accessible to staff users. """
self.client.logout()
self.client.login(username=self.staff.username, password=self.PASSWORD)
self.assert_verification_returned()
def test_owner(self):
""" 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()
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. """
user = UserFactory.create()
user = UserFactory()
self.client.login(username=user.username, password=self.PASSWORD)
response = self.client.get(self.path)
self.assertEqual(response.status_code, 403)
......@@ -88,5 +90,6 @@ class PhotoVerificationStatusViewTests(TestCase):
""" The endpoint should return that the user is verified if the user's verification is accepted. """
self.verification.status = 'approved'
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)
......@@ -156,4 +156,5 @@ class IsStaffOrOwner(permissions.BasePermission):
user = request.user
return user.is_staff \
or (user.username == request.GET.get('username')) \
or (user.username == getattr(request, 'data', {}).get('username'))
or (user.username == getattr(request, 'data', {}).get('username')) \
or (user.username == getattr(view, 'kwargs', {}).get('username'))
......@@ -5,6 +5,7 @@ from django.contrib.auth.models import AnonymousUser
from django.http import Http404
from django.test import TestCase, RequestFactory
from nose.plugins.attrib import attr
from rest_framework.generics import GenericAPIView
from student.roles import CourseStaffRole, CourseInstructorRole
from openedx.core.lib.api.permissions import (
......@@ -37,8 +38,8 @@ class IsCourseStaffInstructorTests(TestCase):
def setUp(self):
super(IsCourseStaffInstructorTests, self).setUp()
self.permission = IsCourseStaffInstructor()
self.coach = UserFactory.create()
self.user = UserFactory.create()
self.coach = UserFactory()
self.user = UserFactory()
self.request = RequestFactory().get('/')
self.request.user = self.user
self.course_key = CourseKey.from_string('edx/test123/run')
......@@ -72,7 +73,7 @@ class IsMasterCourseStaffInstructorTests(TestCase):
super(IsMasterCourseStaffInstructorTests, self).setUp()
self.permission = IsMasterCourseStaffInstructor()
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.user = self.user
self.post_request = RequestFactory().post('/', data={'master_course_id': master_course_id})
......@@ -133,36 +134,44 @@ class IsStaffOrOwnerTests(TestCase):
def test_staff_user(self):
""" Staff users should be permitted. """
user = UserFactory.create(is_staff=True)
user = UserFactory(is_staff=True)
self.assert_user_has_object_permission(user, True)
def test_owner(self):
""" Owners should be permitted. """
user = UserFactory.create()
user = UserFactory()
self.obj.user = user
self.assert_user_has_object_permission(user, True)
def test_non_staff_test_non_owner_or_staff_user(self):
""" Non-staff and non-owner users should not be permitted. """
user = UserFactory.create()
user = UserFactory()
self.assert_user_has_object_permission(user, False)
def test_has_permission_as_staff(self):
""" 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))
def test_has_permission_as_owner_with_get(self):
""" Owners always have permission to make GET actions. """
user = UserFactory.create()
user = UserFactory()
request = RequestFactory().get('/?username={}'.format(user.username))
request.user = user
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')
def test_has_permission_as_owner_with_edit(self, action):
""" Owners always have permission to edit. """
user = UserFactory.create()
user = UserFactory()
data = {'username': user.username}
request = getattr(RequestFactory(), action)('/', data, format='json')
......@@ -172,7 +181,15 @@ class IsStaffOrOwnerTests(TestCase):
def test_has_permission_as_non_owner(self):
""" Non-owners should not have permission. """
user = UserFactory.create()
user = UserFactory()
request = RequestFactory().get('/?username={}'.format(user.username))
request.user = UserFactory.create()
request.user = UserFactory()
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
ddt==0.8.0
django-crum==0.5
django_nose==1.4.1
factory_boy==2.5.1
factory_boy==2.8.1
flaky==3.3.0
freezegun==0.1.11
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