Commit a5001bab by Will Daly

Merge remote-tracking branch 'origin/master' into will/combine-reg-login-form

Conflicts:
	lms/static/sass/base/_grid-settings.scss
	lms/static/sass/shared/_footer.scss
	lms/static/sass/shared/_header.scss
parents dfa81674 3f7d0f24
......@@ -333,7 +333,6 @@ def get_codemirror_value(index=0, find_prefix="$"):
)
def attach_file(filename, sub_path):
path = os.path.join(TEST_ROOT, sub_path, filename)
world.browser.execute_script("$('input.file-input').css('display', 'block')")
......@@ -388,4 +387,3 @@ def create_other_user(_step, name, has_extra_perms, role_name):
@step('I log out')
def log_out(_step):
world.visit('logout')
......@@ -3,7 +3,7 @@
# pylint: disable=C0111
from lettuce import world, step
from nose.tools import assert_true # pylint: disable=E0611
from nose.tools import assert_true # pylint: disable=E0611
from video_editor import RequestHandlerWithSessionId, success_upload_file
......
......@@ -9,6 +9,7 @@ from opaque_keys import InvalidKeyError
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from xmodule.modulestore import ModuleStoreEnum
class Command(BaseCommand):
help = '''Delete a MongoDB backed course'''
......
......@@ -62,7 +62,7 @@ class Command(BaseCommand):
try:
course_key = SlashSeparatedCourseKey.from_deprecated_string(args[0])
except InvalidKeyError:
raise CommandError(GitExportError.BAD_COURSE)
raise CommandError(_(GitExportError.BAD_COURSE))
try:
git_export_utils.export_to_git(
......@@ -72,4 +72,4 @@ class Command(BaseCommand):
options.get('rdir', None)
)
except git_export_utils.GitExportError as ex:
raise CommandError(str(ex))
raise CommandError(_(ex.message))
......@@ -11,6 +11,8 @@ from django.db.utils import IntegrityError
from student.roles import CourseInstructorRole, CourseStaffRole
#------------ to run: ./manage.py cms populate_creators --settings=dev
class Command(BaseCommand):
"""
Script for granting existing course instructors course creator privileges.
......
......@@ -11,8 +11,13 @@ def query_yes_no(question, default="yes"):
The "answer" return value is one of "yes" or "no".
"""
valid = {"yes": True, "y": True, "ye": True,
"no": False, "n": False}
valid = {
"yes": True,
"y": True,
"ye": True,
"no": False,
"n": False,
}
if default is None:
prompt = " [y/n] "
elif default == "yes":
......
......@@ -10,4 +10,3 @@ class Command(BaseCommand):
raise CommandError("restore_asset_from_trashcan requires one argument: <location>")
restore_asset_from_trashcan(args[0])
......@@ -80,6 +80,34 @@ class TestGitExport(CourseTestCase):
stderr=StringIO.StringIO())
self.assertEqual(ex.exception.code, 1)
def test_error_output(self):
"""
Verify that error output is actually resolved as the correct string
"""
output = StringIO.StringIO()
with self.assertRaises(SystemExit):
with self.assertRaisesRegexp(CommandError, GitExportError.BAD_COURSE):
call_command(
'git_export', 'foo/bar:baz', 'silly',
stdout=output, stderr=output
)
self.assertIn('Bad course location provided', output.getvalue())
output.close()
output = StringIO.StringIO()
with self.assertRaises(SystemExit):
with self.assertRaisesRegexp(CommandError, GitExportError.URL_BAD):
call_command(
'git_export', 'foo/bar/baz', 'silly',
stdout=output, stderr=output
)
self.assertIn(
'Non writable git url provided. Expecting something like:'
' git@github.com:mitocw/edx4edx_lite.git',
output.getvalue()
)
output.close()
def test_bad_git_url(self):
"""
Test several bad URLs for validation
......
......@@ -161,7 +161,6 @@ class TestDownloadYoutubeSubs(ModuleStoreTestCase):
number = '999'
display_name = 'Test course'
def clear_sub_content(self, subs_id):
"""
Remove, if subtitle content exists.
......@@ -472,6 +471,7 @@ class TestYoutubeTranscripts(unittest.TestCase):
self.assertEqual(transcripts, expected_transcripts)
mock_get.assert_called_with('http://video.google.com/timedtext', params={'lang': 'en', 'v': 'good_youtube_id'})
class TestTranscript(unittest.TestCase):
"""
Tests for Transcript class e.g. different transcript conversions.
......@@ -489,7 +489,6 @@ class TestTranscript(unittest.TestCase):
""")
self.sjson_transcript = textwrap.dedent("""\
{
"start": [
......
......@@ -86,6 +86,7 @@ class LMSLinksTestCase(TestCase):
link = utils.get_lms_link_for_item(location)
self.assertEquals(link, "//localhost:8000/courses/mitX/101/test/jump_to/i4x://mitX/101/course/test")
class ExtraPanelTabTestCase(TestCase):
""" Tests adding and removing extra course tabs. """
......
......@@ -124,6 +124,7 @@ def expand_checklist_action_url(course_module, checklist):
return expanded_checklist
def localize_checklist_text(checklist):
"""
Localize texts for a given checklist and returns the modified version.
......
......@@ -261,6 +261,7 @@ def course_rerun_handler(request, course_key_string):
'allow_unicode_course_id': settings.FEATURES.get('ALLOW_UNICODE_COURSE_ID', False)
})
def _course_outline_json(request, course_module):
"""
Returns a JSON representation of the course module and recursively all of its children.
......
......@@ -151,7 +151,7 @@ def _preview_module_system(request, descriptor):
replace_urls=partial(static_replace.replace_static_urls, data_directory=None, course_id=course_id),
user=request.user,
can_execute_unsafe_code=(lambda: can_execute_unsafe_code(course_id)),
get_python_lib_zip=(lambda :get_python_lib_zip(contentstore, course_id)),
get_python_lib_zip=(lambda: get_python_lib_zip(contentstore, course_id)),
mixins=settings.XBLOCK_MIXINS,
course_id=course_id,
anonymous_student_id='student',
......
......@@ -20,6 +20,7 @@ from ..utils import get_lms_link_for_item
__all__ = ['tabs_handler']
@expect_json
@login_required
@ensure_csrf_cookie
......@@ -203,4 +204,3 @@ def primitive_insert(course, num, tab_type, name):
tabs = course.tabs
tabs.insert(num, new_tab)
modulestore().update_item(course, ModuleStoreEnum.UserID.primitive_command)
......@@ -377,7 +377,10 @@ def choose_transcripts(request):
if item.sub != html5_id: # update sub value
item.sub = html5_id
item.save_with_metadata(request.user)
response = {'status': 'Success', 'subs': item.sub}
response = {
'status': 'Success',
'subs': item.sub,
}
return JsonResponse(response)
......@@ -408,7 +411,10 @@ def replace_transcripts(request):
item.sub = youtube_id
item.save_with_metadata(request.user)
response = {'status': 'Success', 'subs': item.sub}
response = {
'status': 'Success',
'subs': item.sub,
}
return JsonResponse(response)
......
......@@ -100,7 +100,6 @@ def _course_team_user(request, course_key, email):
}
return JsonResponse(msg, 400)
try:
user = User.objects.get(email=email)
except Exception:
......
......@@ -11,6 +11,7 @@ from models.settings import course_grading
from xmodule.fields import Date
from xmodule.modulestore.django import modulestore
class CourseDetails(object):
def __init__(self, org, course_id, run):
# still need these for now b/c the client's screen shows these 3 fields
......
......@@ -736,7 +736,7 @@ ADVANCED_COMPONENT_TYPES = [
'done', # Lets students mark things as done. See https://github.com/pmitros/DoneXBlock
'audio', # Embed an audio file. See https://github.com/pmitros/AudioXBlock
'recommender', # Crowdsourced recommender. Prototype by dli&pmitros. Intended for roll-out in one place in one course.
'profile', # Prototype user profile XBlock. Used to test XBlock parameter passing. See https://github.com/pmitros/ProfileXBlock
'profile', # Prototype user profile XBlock. Used to test XBlock parameter passing. See https://github.com/pmitros/ProfileXBlock
'split_test',
'combinedopenended',
'peergrading',
......
......@@ -2,7 +2,7 @@
Specific overrides to the base prod settings to make development easier.
"""
from .aws import * # pylint: disable=wildcard-import, unused-wildcard-import
from .aws import * # pylint: disable=wildcard-import, unused-wildcard-import
# Don't use S3 in devstack, fall back to filesystem
del DEFAULT_FILE_STORAGE
......
......@@ -69,9 +69,9 @@ STATICFILES_DIRS += [
# If we don't add these settings, then Django templates that can't
# find pipelined assets will raise a ValueError.
# http://stackoverflow.com/questions/12816941/unit-testing-with-django-pipeline
STATICFILES_STORAGE='pipeline.storage.NonPackagingPipelineStorage'
STATICFILES_STORAGE = 'pipeline.storage.NonPackagingPipelineStorage'
STATIC_URL = "/static/"
PIPELINE_ENABLED=False
PIPELINE_ENABLED = False
# Update module store settings per defaults for tests
update_module_store_settings(
......
......@@ -45,7 +45,8 @@
'js/factories/settings',
'js/factories/settings_advanced',
'js/factories/settings_graders',
'js/factories/textbooks'
'js/factories/textbooks',
'js/factories/xblock_validation'
]),
/**
* By default all the configuration for optimization happens from the command
......
......@@ -244,6 +244,8 @@ define([
"js/spec/views/modals/edit_xblock_spec",
"js/spec/views/modals/validation_error_modal_spec",
"js/spec/factories/xblock_validation_spec",
"js/spec/xblock/cms.runtime.v1_spec",
# these tests are run separately in the cms-squire suite, due to process
......
define(["js/views/xblock_validation", "js/models/xblock_validation"],
function (XBlockValidationView, XBlockValidationModel) {
'use strict';
return function (validationMessages, hasEditingUrl, isRoot, validationEle) {
if (hasEditingUrl && !isRoot) {
validationMessages.showSummaryOnly = true;
}
var model = new XBlockValidationModel(validationMessages, {parse: true});
if (!model.get("empty")) {
new XBlockValidationView({el: validationEle, model: model, root: isRoot}).render();
}
};
});
......@@ -18,10 +18,10 @@ define(["backbone", "gettext", "underscore"], function (Backbone, gettext, _) {
if (!response.empty) {
var summary = "summary" in response ? response.summary : {};
var messages = "messages" in response ? response.messages : [];
if (!(_.has(summary, "text")) || !summary.text) {
if (!summary.text) {
summary.text = gettext("This component has validation issues.");
}
if (!(_.has(summary, "type")) || !summary.type) {
if (!summary.type) {
summary.type = this.WARNING;
// Possible types are ERROR, WARNING, and NOT_CONFIGURED. NOT_CONFIGURED is treated as a warning.
_.find(messages, function (message) {
......
define(['jquery', 'js/factories/xblock_validation', 'js/common_helpers/template_helpers'],
function($, XBlockValidationFactory, TemplateHelpers) {
describe('XBlockValidationFactory', function() {
var messageDiv;
beforeEach(function () {
TemplateHelpers.installTemplate('xblock-validation-messages');
appendSetFixtures($('<div class="messages"></div>'));
messageDiv = $('.messages');
});
it('Does not attach a view if messages is empty', function() {
XBlockValidationFactory({"empty": true}, false, false, messageDiv);
expect(messageDiv.children().length).toEqual(0);
});
it('Does attach a view if messages are not empty', function() {
XBlockValidationFactory({"empty": false}, false, false, messageDiv);
expect(messageDiv.children().length).toEqual(1);
});
it('Passes through the root property to the view.', function() {
var noContainerContent = "no-container-content";
var notConfiguredMessages = {
"empty": false,
"summary": {"text": "my summary", "type": "not-configured"},
"messages": [],
"xblock_id": "id"
};
// Root is false, will not add noContainerContent.
XBlockValidationFactory(notConfiguredMessages, true, false, messageDiv);
expect(messageDiv.find('.validation')).not.toHaveClass(noContainerContent);
// Root is true, will add noContainerContent.
XBlockValidationFactory(notConfiguredMessages, true, true, messageDiv);
expect(messageDiv.find('.validation')).toHaveClass(noContainerContent);
});
describe('Controls display of detailed messages based on url and root property', function() {
var messagesWithSummary, checkDetailedMessages;
beforeEach(function () {
messagesWithSummary = {
"empty": false,
"summary": {"text": "my summary"},
"messages": [{"text": "one", "type": "warning"}, {"text": "two", "type": "error"}],
"xblock_id": "id"
};
});
checkDetailedMessages = function (expectedDetailedMessages) {
expect(messageDiv.children().length).toEqual(1);
expect(messageDiv.find('.xblock-message-item').length).toBe(expectedDetailedMessages);
};
it('Does not show details if xblock has an editing URL and it is not rendered as root', function() {
XBlockValidationFactory(messagesWithSummary, true, false, messageDiv);
checkDetailedMessages(0);
});
it('Shows details if xblock does not have its own editing URL, regardless of root value', function() {
XBlockValidationFactory(messagesWithSummary, false, false, messageDiv);
checkDetailedMessages(2);
XBlockValidationFactory(messagesWithSummary, false, true, messageDiv);
checkDetailedMessages(2);
});
it('Shows details if xblock has its own editing URL and is rendered as root', function() {
XBlockValidationFactory(messagesWithSummary, true, true, messageDiv);
checkDetailedMessages(2);
});
});
});
}
);
......@@ -52,7 +52,7 @@
</div>
</article>
<aside class="content-supplementary" role="complimentary">
<aside class="content-supplementary" role="complementary">
<div class="bit">
<h3 class="title-3">${_("Adding Files for Your Course")}</h3>
......
......@@ -42,7 +42,7 @@
</form>
</article>
<aside class="content-supplementary" role="complimentary">
<aside class="content-supplementary" role="complementary">
<div class="bit">
<h3 class="title title-3">${_("What are course checklists?")}</h3>
<p>
......
......@@ -106,7 +106,7 @@ templates = ["basic-modal", "modal-button", "edit-xblock-modal",
<p><span class="spin"><i class="icon-refresh"></i></span> <span class="copy">${_("Loading...")}</span></p>
</div>
</article>
<aside class="content-supplementary" role="complimentary">
<aside class="content-supplementary" role="complementary">
% if not is_unit_page:
<div class="bit">
<h3 class="title-3">${_("Adding components")}</h3>
......
......@@ -123,7 +123,7 @@
</article>
<aside class="content-supplementary" role="complimentary">
<aside class="content-supplementary" role="complementary">
<div class="bit">
<h3 class="title-3">${_("When will my course re-run start?")}</h3>
<ul class="list-details">
......
......@@ -114,7 +114,7 @@ from contentstore.utils import reverse_usage_url
<p><span class="spin"><i class="icon-refresh"></i></span> <span class="copy">${_("Loading...")}</span></p>
</div>
</article>
<aside class="content-supplementary" role="complimentary">
<aside class="content-supplementary" role="complementary">
<div class="bit">
<h3 class="title-3">${_("Creating your course organization")}</h3>
<p>${_("You add sections, subsections, and units directly in the outline.")}</p>
......
......@@ -137,7 +137,7 @@
</div>
</article>
<aside class="content-supplementary" role="complimentary">
<aside class="content-supplementary" role="complementary">
<div class="bit">
<h3 class="title-3">${_("What are pages?")}</h3>
<p>${_("Pages are listed horizontally at the top of your course. Default pages (Courseware, Course info, Discussion, Wiki, and Progress) are followed by textbooks and custom pages that you create.")}</p>
......
......@@ -84,7 +84,7 @@
</div>
</article>
<aside class="content-supplementary" role="complimentary">
<aside class="content-supplementary" role="complementary">
<div class="bit">
<h3 class="title-3">${_("Why export a course?")}</h3>
<p>${_("You may want to edit the XML in your course directly, outside of Studio. You may want to create a backup copy of your course. Or, you may want to create a copy of your course that you can later import into another course instance and customize.")}</p>
......
......@@ -57,7 +57,7 @@
% endif
</div>
</article>
<aside class="content-supplementary" role="complimentary">
<aside class="content-supplementary" role="complementary">
<dl class="export-git-info-block">
<dt>${_("Your course:")}</dt>
<dd class="course_text">${context_course.id | h}</dd>
......
......@@ -59,7 +59,7 @@
</div>
% endif
</article>
<aside class="content-supplementary" role="complimentary">
<aside class="content-supplementary" role="complementary">
<div class="bit">
<h3 class="title-3">${_("What can I do on this page?")}</h3>
<p>${_("You can create, edit, and delete group configurations.")}</p>
......
......@@ -127,7 +127,7 @@
</form>
</article>
<aside class="content-supplementary" role="complimentary">
<aside class="content-supplementary" role="complementary">
<div class="bit">
<h3 class="title-3">${_("Why import a course?")}</h3>
<p>${_("You may want to run a new version of an existing course, or replace an existing course altogether. Or, you may have developed a course outside Studio.")}</p>
......
......@@ -357,7 +357,7 @@
% endif
</article>
<aside class="content-supplementary" role="complimentary">
<aside class="content-supplementary" role="complementary">
<div class="bit">
<h3 class="title title-3">${_('New to edX Studio?')}</h3>
<p>${_('Click Help in the upper-right corner to get more information about the Studio page you are viewing. You can also use the links at the bottom of the page to access our continously updated documentation and other Studio resources.')}</p>
......@@ -422,7 +422,7 @@
</div>
</article>
<aside class="content-supplementary" role="complimentary">
<aside class="content-supplementary" role="complementary">
<div class="bit">
<h3 class="title title-3">${_('Need help?')}</h3>
<p>${_('Please check your Junk or Spam folders in case our email isn\'t in your INBOX. Still can\'t find the verification email? Request help via the link below.')}</p>
......
......@@ -50,7 +50,7 @@
<p><span class="spin"><i class="icon-refresh"></i></span> <span class="copy">Loading...</span></p>
</div>
</article>
<aside class="content-supplementary" role="complimentary">
<aside class="content-supplementary" role="complementary">
<div id="publish-unit" class="window"></div>
<div id="publish-history"></div>
</aside>
......
......@@ -47,7 +47,7 @@
<p><span class="spin"><i class="icon-refresh"></i></span> <span class="copy">Loading...</span></p>
</div>
</article>
<aside class="content-supplementary" role="complimentary">
<aside class="content-supplementary" role="complementary">
<div class="bit">
<h3 class="title-3">What can I do on this page?</h3>
<p>You can create new sections and subsections, set the release date for sections, and create new units in existing subsections. You can set the assignment type for subsections that are to be graded, and you can open a subsection for further editing.</p>
......
......@@ -23,7 +23,7 @@
<p><span class="spin"><i class="icon-refresh"></i></span> <span class="copy">${_("Loading...")}</span></p>
</div>
</article>
<aside class="content-supplementary" role="complimentary"></aside>
<aside class="content-supplementary" role="complementary"></aside>
</section>
</div>
</div>
......@@ -45,7 +45,7 @@ from django.utils.translation import ugettext as _
</form>
</article>
<aside class="content-supplementary" role="complimentary">
<aside class="content-supplementary" role="complementary">
<h2 class="sr">${_("Studio Support")}</h2>
<div class="bit">
......
......@@ -143,7 +143,7 @@
%endif
</article>
<aside class="content-supplementary" role="complimentary">
<aside class="content-supplementary" role="complementary">
<div class="bit">
<h3 class="title-3">${_("Course Team Roles")}</h3>
<p>${_("Course team members, or staff, are course co-authors. They have full writing and editing privileges on all course content.")}</p>
......
......@@ -77,7 +77,7 @@
</form>
</article>
<aside class="content-supplementary" role="complimentary">
<aside class="content-supplementary" role="complementary">
<h2 class="sr">${_("Common Studio Questions")}</h2>
<div class="bit">
......
......@@ -292,7 +292,7 @@ CMS.URL.UPLOAD_ASSET = '${upload_asset_url}';
% endif
</form>
</article>
<aside class="content-supplementary" role="complimentary">
<aside class="content-supplementary" role="complementary">
<div class="bit">
<h3 class="title-3">${_("How are these settings used?")}</h3>
<p>${_("Your course's schedule determines when students can enroll in and begin a course.")}</p>
......
......@@ -68,7 +68,7 @@
</form>
</article>
<aside class="content-supplementary" role="complimentary">
<aside class="content-supplementary" role="complementary">
<div class="bit">
<h3 class="title-3">${_("What do advanced settings do?")}</h3>
<p>${_("Advanced settings control specific course functionality. On this page, you can edit manual policies, which are JSON-based key and value pairs that control specific course settings.")}</p>
......
......@@ -112,7 +112,7 @@
</form>
</article>
<aside class="content-supplementary" role="complimentary">
<aside class="content-supplementary" role="complementary">
<div class="bit">
<h3 class="title-3">${_("What can I do on this page?")}</h3>
<p>${_("You can use the slider under Overall Grade Range to specify whether your course is pass/fail or graded by letter, and to establish the thresholds for each grade.")}</p>
......
<%!
from django.utils.translation import ugettext as _
from contentstore.views.helpers import xblock_studio_url
......@@ -21,31 +20,17 @@ messages = json.dumps(xblock.validate().to_json())
</script>
</%block>
<script type='text/javascript'>
require(["js/views/xblock_validation", "js/models/xblock_validation"],
function (XBlockValidationView, XBlockValidationModel) {
var validationMessages = ${messages};
% if xblock_url and not is_root:
validationMessages.showSummaryOnly = true;
% endif
var model = new XBlockValidationModel(validationMessages, {parse: true});
if (!model.get("empty")) {
var validationEle = $('div.xblock-validation-messages[data-locator="${xblock.location | h}"]');
var viewOptions = {
el: validationEle,
model: model
};
% if is_root:
viewOptions.root = true;
% endif
var view = new XBlockValidationView(viewOptions);
view.render();
}
});
<script>
require(["jquery", "js/factories/xblock_validation"], function($, XBlockValidationFactory) {
XBlockValidationFactory(
${messages},
$.parseJSON("${bool(xblock_url)}".toLowerCase()), // xblock_url will be None or a string
$.parseJSON("${bool(is_root)}".toLowerCase()), // is_root will be None or a boolean
$('div.xblock-validation-messages[data-locator="${xblock.location | h}"]')
);
});
</script>
% if not is_root:
% if is_reorderable:
<li class="studio-xblock-wrapper is-draggable" data-locator="${xblock.location | h}" data-course-key="${xblock.location.course_key | h}">
......
......@@ -54,7 +54,7 @@ CMS.URL.LMS_BASE = "${settings.LMS_BASE}"
<article class="content-primary" role="main">
</article>
<aside class="content-supplementary" role="complimentary">
<aside class="content-supplementary" role="complementary">
<div class="bit">
<h3 class="title-3">${_("Why should I break my textbook into chapters?")}</h3>
<p>${_("Breaking your textbook into multiple chapters reduces loading times for students, especially those with slow Internet connections. Breaking up textbooks into chapters can also help students more easily find topic-based information.")}</p>
......
......@@ -455,7 +455,7 @@
</article>
</section>
</article>
<aside class="content-supplementary" role="complimentary">
<aside class="content-supplementary" role="complementary">
<!-- begin publishing changes 1 -->
......
......@@ -325,7 +325,7 @@
</article><!-- /content-primary -->
<aside class="content-supplementary" role="complimentary">
<aside class="content-supplementary" role="complementary">
<div class="bit">
<h3 class="title-3">When will my course re-run start?</h3>
<ul class="list-details">
......
......@@ -757,7 +757,7 @@ from django.core.urlresolvers import reverse
</article>
<aside class="content-supplementary" role="complimentary">
<aside class="content-supplementary" role="complementary">
<div class="bit">
<h3 class="title-3">What can I do on this page?</h3>
<p>You can create new sections and subsections, set the release date for sections, and create new units in existing subsections. You can set the assignment type for subsections that are to be graded, and you can open a subsection for further editing.</p>
......
......@@ -17,4 +17,3 @@ startup.run()
# as well as any WSGI server configured to use this file.
from django.core.wsgi import get_wsgi_application
application = get_wsgi_application()
......@@ -22,6 +22,7 @@ from xmodule.exceptions import NotFoundError
log = logging.getLogger(__name__)
class StaticContentServer(object):
def process_request(self, request):
# look to see if the request is prefixed with an asset prefix tag
......
......@@ -322,6 +322,7 @@ def add_cohort(course_key, name):
)
return cohort
def add_user_to_cohort(cohort, username_or_email):
"""
Look up the given user, and if successful, add them to the specified cohort.
......
......@@ -209,6 +209,7 @@ class ListCohortsTestCase(CohortViewsTestCase):
actual_cohorts,
)
class AddCohortTestCase(CohortViewsTestCase):
"""
Tests the `add_cohort` view.
......
......@@ -151,7 +151,6 @@ class CourseModeViewTest(ModuleStoreTestCase):
response = self.client.get(choose_track_url)
self.assertRedirects(response, reverse('dashboard'))
# Mapping of course modes to the POST parameters sent
# when the user chooses that mode.
POST_PARAMS_FOR_COURSE_MODE = {
......
......@@ -94,6 +94,7 @@ class ChooseModeView(View):
"error": error,
"upgrade": upgrade,
"can_audit": "audit" in modes,
"responsive": True
}
if "verified" in modes:
context["suggested_prices"] = [
......
......@@ -144,7 +144,6 @@ class DarkLangMiddlewareTests(TestCase):
self.process_request(accept='rel-ter;q=1.0, rel;q=0.5')
)
def assertSessionLangEquals(self, value, request):
"""
Assert that the 'django_language' set in request.session is equal to value
......
......@@ -4,6 +4,7 @@ from opaque_keys.edx.locations import SlashSeparatedCourseKey
from django_comment_common.models import Role
from student.models import CourseEnrollment, User
class RoleAssignmentTest(TestCase):
"""
Basic checks to make sure our Roles get assigned and unassigned as students
......
......@@ -34,7 +34,6 @@ class MakoLoader(object):
self.module_directory = module_directory
def __call__(self, template_name, template_dirs=None):
return self.load_template(template_name, template_dirs)
......
......@@ -32,6 +32,7 @@ def clear_lookups(namespace):
if namespace in LOOKUP:
del LOOKUP[namespace]
def add_lookup(namespace, directory, package=None, prepend=False):
"""
Adds a new mako template lookup directory to the given namespace.
......
......@@ -76,6 +76,7 @@ def marketing_link_context_processor(request):
]
)
def open_source_footer_context_processor(request):
"""
Checks the site name to determine whether to use the edX.org footer or the Open Source Footer.
......@@ -97,6 +98,7 @@ def microsite_footer_context_processor(request):
]
)
def render_to_string(template_name, dictionary, context=None, namespace='main'):
# see if there is an override template defined in the microsite
......
......@@ -19,6 +19,7 @@ from edxmako.shortcuts import (
from student.tests.factories import UserFactory
from util.testing import UrlResetMixin
@ddt.ddt
class ShortcutsTests(UrlResetMixin, TestCase):
"""
......
......@@ -28,6 +28,7 @@ from embargo.models import EmbargoedCourse, EmbargoedState, IPFilter
# that disables the XML modulestore.
MODULESTORE_CONFIG = mixed_store_config(settings.COMMON_TEST_DATA_ROOT, {}, include_xml=False)
@ddt.ddt
@override_settings(MODULESTORE=MODULESTORE_CONFIG)
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
......
......@@ -37,6 +37,7 @@ FEATURES_WITHOUT_SSL_AUTH['AUTH_USE_CERTIFICATES'] = False
TEST_DATA_MIXED_MODULESTORE = mixed_store_config(settings.COMMON_TEST_DATA_ROOT, {})
@override_settings(FEATURES=FEATURES_WITH_SSL_AUTH)
class SSLClientTest(ModuleStoreTestCase):
"""
......
......@@ -8,6 +8,7 @@ from django.db.utils import DatabaseError
import mock
from django.test.testcases import TestCase
class HeartbeatTestCase(TestCase):
"""
Test the heartbeat
......
......@@ -23,6 +23,7 @@ def page_title_breadcrumbs(*crumbs, **kwargs):
else:
return platform_name()
@register.simple_tag(name="page_title_breadcrumbs", takes_context=True)
def page_title_breadcrumbs_tag(context, *crumbs):
"""
......@@ -42,7 +43,7 @@ def platform_name():
@register.simple_tag(name="favicon_path")
def favicon_path(default=getattr(settings,'FAVICON_PATH', 'images/favicon.ico')):
def favicon_path(default=getattr(settings, 'FAVICON_PATH', 'images/favicon.ico')):
"""
Django template tag that outputs the configured favicon:
{% favicon_path %}
......
......@@ -78,6 +78,7 @@ def post_save_metrics(sender, **kwargs):
tags = _database_tags(action, sender, kwargs)
dog_stats_api.increment('edxapp.db.model', tags=tags)
@receiver(post_delete, dispatch_uid='edxapp.monitoring.post_delete_metrics')
def post_delete_metrics(sender, **kwargs):
"""
......
# Register signal handlers
import signals
import exceptions
\ No newline at end of file
import exceptions
......@@ -3,11 +3,12 @@ import threading
_request_cache_threadlocal = threading.local()
_request_cache_threadlocal.data = {}
class RequestCache(object):
@classmethod
def get_request_cache(cls):
return _request_cache_threadlocal
def clear_request_cache(self):
_request_cache_threadlocal.data = {}
......@@ -17,4 +18,4 @@ class RequestCache(object):
def process_response(self, request, response):
self.clear_request_cache()
return response
\ No newline at end of file
return response
......@@ -36,7 +36,6 @@ class TestStatus(TestCase):
"edX/toy/2012_Fall" : "A toy story"
}"""
# json to use, expected results for course=None (e.g. homepage),
# for toy course, for full course. Note that get_site_status_msg
# is supposed to return global message even if course=None. The
......
......@@ -83,4 +83,3 @@ def _check_caller_authority(caller, role):
elif isinstance(role, CourseRole): # instructors can change the roles w/in their course
if not has_access(caller, CourseInstructorRole(role.course_key)):
raise PermissionDenied
......@@ -22,4 +22,4 @@ class PasswordResetFormNoActive(PasswordResetForm):
if any((user.password == UNUSABLE_PASSWORD)
for user in self.users_cache):
raise forms.ValidationError(self.error_messages['unusable'])
return email
\ No newline at end of file
return email
......@@ -65,4 +65,3 @@ class Command(BaseCommand):
))
except IOError:
raise CommandError("Error writing to file: %s" % output_filename)
......@@ -83,7 +83,7 @@ class Command(TrackedCommand):
# Move the Student between the classes.
mode = enrollment.mode
old_is_active = enrollment.is_active
CourseEnrollment.unenroll(user, source_key, emit_unenrollment_event=False)
CourseEnrollment.unenroll(user, source_key, skip_refund=True)
print(u"Unenrolled {} from {}".format(user.username, unicode(source_key)))
for dest_key in dest_keys:
......@@ -98,7 +98,7 @@ class Command(TrackedCommand):
# Un-enroll from the new course if the user had un-enrolled
# form the old course.
if not old_is_active:
new_enrollment.update_enrollment(is_active=False, emit_unenrollment_event=False)
new_enrollment.update_enrollment(is_active=False, skip_refund=True)
if transfer_certificates:
self._transfer_certificate_item(source_key, enrollment, user, dest_keys, new_enrollment)
......
......@@ -2,11 +2,16 @@
Tests the transfer student management command
"""
from django.conf import settings
from mock import patch, call
from opaque_keys.edx import locator
import unittest
import ddt
from shoppingcart.models import Order, CertificateItem # pylint: disable=F0401
from course_modes.models import CourseMode
from student.management.commands import transfer_students
from student.models import CourseEnrollment
from student.models import CourseEnrollment, UNENROLL_DONE, EVENT_NAME_ENROLLMENT_DEACTIVATED, \
EVENT_NAME_ENROLLMENT_ACTIVATED, EVENT_NAME_ENROLLMENT_MODE_CHANGED
from student.tests.factories import UserFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
......@@ -18,18 +23,40 @@ class TestTransferStudents(ModuleStoreTestCase):
"""Tests for transferring students between courses."""
PASSWORD = 'test'
signal_fired = False
def setUp(self, **kwargs):
"""Connect a stub receiver, and analytics event tracking."""
UNENROLL_DONE.connect(self.assert_unenroll_signal)
patcher = patch('student.models.tracker')
self.mock_tracker = patcher.start()
self.addCleanup(patcher.stop)
def tearDown(self):
"""Disconnects the UNENROLL stub receiver."""
UNENROLL_DONE.disconnect(self.assert_unenroll_signal)
def assert_unenroll_signal(self, skip_refund=False, **kwargs): # pylint: disable=W0613
""" Signal Receiver stub for testing that the unenroll signal was fired. """
self.assertFalse(self.signal_fired)
self.assertTrue(skip_refund)
self.signal_fired = True
def test_transfer_students(self):
student = UserFactory()
""" Verify the transfer student command works as intended. """
student = UserFactory.create()
student.set_password(self.PASSWORD) # pylint: disable=E1101
student.save() # pylint: disable=E1101
mode = 'verified'
# Original Course
original_course_location = locator.CourseLocator('Org0', 'Course0', 'Run0')
course = self._create_course(original_course_location)
# Enroll the student in 'verified'
CourseEnrollment.enroll(student, course.id, mode="verified")
# Create and purchase a verified cert for the original course.
self._create_and_purchase_verified(student, course.id)
# New Course 1
course_location_one = locator.CourseLocator('Org1', 'Course1', 'Run1')
new_course_one = self._create_course(course_location_one)
......@@ -45,11 +72,55 @@ class TestTransferStudents(ModuleStoreTestCase):
transfer_students.Command().handle(
source_course=original_key, dest_course_list=new_key_one + "," + new_key_two
)
self.assertTrue(self.signal_fired)
# Confirm the analytics event was emitted.
self.mock_tracker.emit.assert_has_calls( # pylint: disable=E1103
[
call(
EVENT_NAME_ENROLLMENT_ACTIVATED,
{'course_id': original_key, 'user_id': student.id, 'mode': mode}
),
call(
EVENT_NAME_ENROLLMENT_MODE_CHANGED,
{'course_id': original_key, 'user_id': student.id, 'mode': mode}
),
call(
EVENT_NAME_ENROLLMENT_DEACTIVATED,
{'course_id': original_key, 'user_id': student.id, 'mode': mode}
),
call(
EVENT_NAME_ENROLLMENT_ACTIVATED,
{'course_id': new_key_one, 'user_id': student.id, 'mode': mode}
),
call(
EVENT_NAME_ENROLLMENT_MODE_CHANGED,
{'course_id': new_key_one, 'user_id': student.id, 'mode': mode}
),
call(
EVENT_NAME_ENROLLMENT_ACTIVATED,
{'course_id': new_key_two, 'user_id': student.id, 'mode': mode}
),
call(
EVENT_NAME_ENROLLMENT_MODE_CHANGED,
{'course_id': new_key_two, 'user_id': student.id, 'mode': mode}
)
]
)
self.mock_tracker.reset_mock()
# Confirm the enrollment mode is verified on the new courses, and enrollment is enabled as appropriate.
self.assertEquals(('verified', False), CourseEnrollment.enrollment_mode_for_user(student, course.id))
self.assertEquals(('verified', True), CourseEnrollment.enrollment_mode_for_user(student, new_course_one.id))
self.assertEquals(('verified', True), CourseEnrollment.enrollment_mode_for_user(student, new_course_two.id))
self.assertEquals((mode, False), CourseEnrollment.enrollment_mode_for_user(student, course.id))
self.assertEquals((mode, True), CourseEnrollment.enrollment_mode_for_user(student, new_course_one.id))
self.assertEquals((mode, True), CourseEnrollment.enrollment_mode_for_user(student, new_course_two.id))
# Confirm the student has not be refunded.
target_certs = CertificateItem.objects.filter(
course_id=course.id, user_id=student, status='purchased', mode=mode
)
self.assertTrue(target_certs[0])
self.assertFalse(target_certs[0].refund_requested_time)
self.assertEquals(target_certs[0].order.status, 'purchased')
def _create_course(self, course_location):
""" Creates a course """
......@@ -58,3 +129,15 @@ class TestTransferStudents(ModuleStoreTestCase):
number=course_location.course,
run=course_location.run
)
def _create_and_purchase_verified(self, student, course_id):
""" Creates a verified mode for the course and purchases it for the student. """
course_mode = CourseMode(course_id=course_id,
mode_slug="verified",
mode_display_name="verified cert",
min_price=50)
course_mode.save()
# When there is no expiration date on a verified mode, the user can always get a refund
cart = Order.get_cart_for_user(user=student)
CertificateItem.add_to_order(cart, course_id, 50, 'verified')
cart.purchase()
......@@ -7,6 +7,7 @@ from django.utils.translation import ugettext as _
from django.conf import settings
from student.models import UserStanding
class UserStandingMiddleware(object):
"""
Checks a user's standing on request. Returns a 403 if the user's
......
......@@ -18,12 +18,10 @@ class Migration(SchemaMigration):
))
db.send_create_signal('student', ['DashboardConfiguration'])
def backwards(self, orm):
# Deleting model 'DashboardConfiguration'
db.delete_table('student_dashboardconfiguration')
models = {
'auth.group': {
'Meta': {'object_name': 'Group'},
......@@ -176,4 +174,4 @@ class Migration(SchemaMigration):
}
}
complete_apps = ['student']
\ No newline at end of file
complete_apps = ['student']
......@@ -56,7 +56,7 @@ from ratelimitbackend import admin
import analytics
UNENROLL_DONE = Signal(providing_args=["course_enrollment"])
UNENROLL_DONE = Signal(providing_args=["course_enrollment", "skip_refund"])
log = logging.getLogger(__name__)
AUDIT_LOG = logging.getLogger("audit")
SessionStore = import_module(settings.SESSION_ENGINE).SessionStore # pylint: disable=invalid-name
......@@ -665,15 +665,19 @@ class LoginFailures(models.Model):
class CourseEnrollmentException(Exception):
pass
class NonExistentCourseError(CourseEnrollmentException):
pass
class EnrollmentClosedError(CourseEnrollmentException):
pass
class CourseFullError(CourseEnrollmentException):
pass
class AlreadyEnrolledError(CourseEnrollmentException):
pass
......@@ -776,7 +780,7 @@ class CourseEnrollment(models.Model):
is_course_full = cls.num_enrolled_in(course.id) >= course.max_student_enrollments_allowed
return is_course_full
def update_enrollment(self, mode=None, is_active=None, emit_unenrollment_event=True):
def update_enrollment(self, mode=None, is_active=None, skip_refund=False):
"""
Updates an enrollment for a user in a class. This includes options
like changing the mode, toggling is_active True/False, etc.
......@@ -814,8 +818,8 @@ class CourseEnrollment(models.Model):
u"mode:{}".format(self.mode)]
)
elif emit_unenrollment_event:
UNENROLL_DONE.send(sender=None, course_enrollment=self)
else:
UNENROLL_DONE.send(sender=None, course_enrollment=self, skip_refund=skip_refund)
self.emit_event(EVENT_NAME_ENROLLMENT_DEACTIVATED)
......@@ -988,7 +992,7 @@ class CourseEnrollment(models.Model):
raise
@classmethod
def unenroll(cls, user, course_id, emit_unenrollment_event=True):
def unenroll(cls, user, course_id, skip_refund=False):
"""
Remove the user from a given course. If the relevant `CourseEnrollment`
object doesn't exist, we log an error but don't throw an exception.
......@@ -999,11 +1003,11 @@ class CourseEnrollment(models.Model):
`course_id` is our usual course_id string (e.g. "edX/Test101/2013_Fall)
`emit_unenrollment_events` can be set to False to suppress events firing.
`skip_refund` can be set to True to avoid the refund process.
"""
try:
record = CourseEnrollment.objects.get(user=user, course_id=course_id)
record.update_enrollment(is_active=False, emit_unenrollment_event=emit_unenrollment_event)
record.update_enrollment(is_active=False, skip_refund=skip_refund)
except cls.DoesNotExist:
err_msg = u"Tried to unenroll student {} from {} but they were not enrolled"
......
......@@ -210,6 +210,7 @@ class CourseFinanceAdminRole(CourseRole):
def __init__(self, *args, **kwargs):
super(CourseFinanceAdminRole, self).__init__(self.ROLE, *args, **kwargs)
class CourseBetaTesterRole(CourseRole):
"""A course Beta Tester"""
ROLE = 'beta_testers'
......
......@@ -11,6 +11,7 @@ from opaque_keys.edx.locator import CourseLocator
from mock import patch
import ddt
@ddt.ddt
class AutoAuthEnabledTestCase(UrlResetMixin, TestCase):
"""
......
......@@ -105,7 +105,10 @@ class TestCourseListing(ModuleStoreTestCase):
course_location = SlashSeparatedCourseKey('testOrg', 'erroredCourse', 'RunBabyRun')
course = self._create_course_with_access_groups(course_location)
course_db_record = mongo_store._find_one(course.location)
course_db_record.setdefault('metadata', {}).get('tabs', []).append({"type": "wiko", "name": "Wiki" })
course_db_record.setdefault('metadata', {}).get('tabs', []).append({
"type": "wiko",
"name": "Wiki",
})
mongo_store.collection.update(
{'_id': course.location.to_deprecated_son()},
{'$set': {
......
......@@ -485,6 +485,7 @@ class LoginOAuthTokenMixin(object):
self._setup_user_response(success=True)
response = self.client.post(self.url, {"access_token": "dummy"})
self.assertEqual(response.status_code, 204)
self.assertEqual(self.client.session['_auth_user_id'], self.user.id)
def test_invalid_token(self):
self._setup_user_response(success=False)
......
......@@ -25,6 +25,7 @@ FAKE_MICROSITE = {
]
}
def fake_site_name(name, default=None): # pylint: disable=W0613
"""
create a fake microsite site name
......@@ -34,12 +35,14 @@ def fake_site_name(name, default=None): # pylint: disable=W0613
else:
return default
def fake_microsite_get_value(name, default=None): # pylint: disable=W0613
"""
create a fake microsite site name
"""
return FAKE_MICROSITE.get(name, default)
class TestMicrosite(TestCase):
"""Test for Account Creation from a white labeled Micro-Sites"""
def setUp(self):
......
......@@ -15,6 +15,7 @@ from edxmako.tests import mako_middleware_process_request
from external_auth.models import ExternalAuthMap
from student.views import create_account
@patch.dict("django.conf.settings.FEATURES", {'ENFORCE_PASSWORD_POLICY': True})
class TestPasswordPolicy(TestCase):
"""
......
......@@ -265,6 +265,7 @@ class DashboardTest(ModuleStoreTestCase):
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
@patch('courseware.views.log.warning')
@patch.dict('django.conf.settings.FEATURES', {'ENABLE_PAID_COURSE_REGISTRATION': True})
def test_blocked_course_scenario(self, log_warning):
self.client.login(username="jack", password="test")
......
......@@ -1115,6 +1115,7 @@ def login_user(request, error=""): # pylint: disable-msg=too-many-statements,un
}) # TODO: this should be status code 400 # pylint: disable=fixme
@csrf_exempt
@require_POST
@social_utils.strategy("social:complete")
def login_oauth_token(request, backend):
......@@ -1135,6 +1136,7 @@ def login_oauth_token(request, backend):
pass
# do_auth can return a non-User object if it fails
if user and isinstance(user, User):
login(request, user)
return JsonResponse(status=204)
else:
# Ensure user does not re-enter the pipeline
......@@ -1791,11 +1793,9 @@ def activate_account(request, key):
@csrf_exempt
@require_POST
def password_reset(request):
""" Attempts to send a password reset e-mail. """
if request.method != "POST":
raise Http404
# Add some rate limiting here by re-using the RateLimitMixin as a helper class
limiter = BadRequestRateLimiter()
if limiter.is_rate_limit_exceeded(request):
......
......@@ -95,7 +95,9 @@ def initial_setup(server):
if browser_driver == 'chrome':
desired_capabilities = DesiredCapabilities.CHROME
desired_capabilities['loggingPrefs'] = { 'browser':'ALL' }
desired_capabilities['loggingPrefs'] = {
'browser': 'ALL',
}
elif browser_driver == 'firefox':
desired_capabilities = DesiredCapabilities.FIREFOX
else:
......@@ -239,7 +241,7 @@ def capture_console_log(scenario):
output_dir = '{}/log'.format(settings.TEST_ROOT)
file_name = '{}/{}.log'.format(output_dir, scenario.name.replace(' ', '_'))
with open (file_name, 'w') as output_file:
with open(file_name, 'w') as output_file:
for line in log:
output_file.write("{}{}".format(dumps(line), '\n'))
......
......@@ -6,6 +6,7 @@ import re
import urlparse
from .http import StubHttpRequestHandler, StubHttpService
class StubCommentsServiceHandler(StubHttpRequestHandler):
@property
......
......@@ -123,8 +123,8 @@ class StubHttpRequestHandler(BaseHTTPRequestHandler, object):
# By default, `parse_qs` returns a list of values for each param
# For convenience, we replace lists of 1 element with just the element
return {
k:v[0] if len(v) == 1 else v
for k,v in urlparse.parse_qs(query).items()
key: value[0] if len(value) == 1 else value
for key, value in urlparse.parse_qs(query).items()
}
@lazy
......
......@@ -21,6 +21,7 @@ import mock
import requests
from http import StubHttpRequestHandler, StubHttpService
class StubLtiHandler(StubHttpRequestHandler):
"""
A handler for LTI POST and GET requests.
......
......@@ -45,7 +45,7 @@ class StudentState(object):
@property
def num_pending(self):
return max(self.INITIAL_ESSAYS_AVAILABLE- self.num_graded, 0)
return max(self.INITIAL_ESSAYS_AVAILABLE - self.num_graded, 0)
@property
def num_required(self):
......@@ -300,7 +300,6 @@ class StubOraHandler(StubHttpRequestHandler):
"""
self._success_response({'problem_list': self.server.problem_list})
@require_params('POST', 'grader_id', 'location', 'submission_id', 'score', 'feedback', 'submission_key')
def _save_grade(self):
"""
......@@ -421,7 +420,6 @@ class StubOraHandler(StubHttpRequestHandler):
)
self.send_response(400)
def _student(self, method, key='student_id'):
"""
Return the `StudentState` instance for the student ID given
......
......@@ -25,7 +25,9 @@ class StubHttpServiceTest(unittest.TestCase):
'test_empty': '',
'test_int': 12345,
'test_float': 123.45,
'test_dict': { 'test_key': 'test_val' },
'test_dict': {
'test_key': 'test_val',
},
'test_empty_dict': {},
'test_unicode': u'\u2603 the snowman',
'test_none': None,
......
......@@ -7,6 +7,7 @@ import urllib2
import requests
from terrain.stubs.lti import StubLtiService
class StubLtiServiceTest(unittest.TestCase):
"""
A stub of the LTI provider that listens on a local
......@@ -34,7 +35,7 @@ class StubLtiServiceTest(unittest.TestCase):
'launch_presentation_return_url': '',
'lis_outcome_service_url': 'http://localhost:8001/test_callback',
'lis_result_sourcedid': '',
'resource_link_id':'',
'resource_link_id': '',
}
def test_invalid_request_url(self):
......
......@@ -9,6 +9,7 @@ import os
from logging import getLogger
LOGGER = getLogger(__name__)
class VideoSourceRequestHandler(SimpleHTTPRequestHandler):
"""
Request handler for serving video sources locally.
......
......@@ -214,6 +214,7 @@ class StubXQueueService(StubHttpService):
except for 'default' and 'register_submission_url' which have special meaning
"""
return {
key:val for key, val in self.config.iteritems()
key: value
for key, value in self.config.iteritems()
if key not in self.NON_QUEUE_CONFIG_KEYS
}.items()
......@@ -106,7 +106,7 @@ class TrackMiddleware(object):
for header_name, context_key in META_KEY_TO_CONTEXT_KEY.iteritems():
context[context_key] = request.META.get(header_name, '')
# Google Analytics uses the clientId to keep track of unique visitors. A GA cookie looks like
# Google Analytics uses the clientId to keep track of unique visitors. A GA cookie looks like
# this: _ga=GA1.2.1033501218.1368477899. The clientId is this part: 1033501218.1368477899.
google_analytics_cookie = request.COOKIES.get('_ga')
if google_analytics_cookie is None:
......
......@@ -5,6 +5,7 @@ from student.tests.factories import UserFactory
from user_api.models import UserPreference, UserCourseTag
from opaque_keys.edx.locations import SlashSeparatedCourseKey
# Factories don't have __init__ methods, and are self documenting
# pylint: disable=W0232, C0111
class UserPreferenceFactory(DjangoModelFactory):
......
......@@ -298,7 +298,7 @@ class AccountApiTest(TestCase):
if create_inactive_account:
# Create an account, but do not activate it
account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
account_api.request_password_change(self.EMAIL, self.ORIG_HOST, self.IS_SECURE)
# Verify that no email messages have been sent
......
......@@ -4,6 +4,7 @@ which can be used for rate limiting
"""
from ratelimitbackend.backends import RateLimitMixin
class BadRequestRateLimiter(RateLimitMixin):
"""
Use the 3rd party RateLimitMixin to help do rate limiting on the Password Reset flows
......
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