Commit f33f5434 by Feanil Patel

Merge pull request #2446 from edx/rc/2014-02-05

rc/2014-02-05
parents e0dd42cc 10364887
...@@ -30,14 +30,14 @@ codekit-config.json ...@@ -30,14 +30,14 @@ codekit-config.json
### Internationalization artifacts ### Internationalization artifacts
*.mo *.mo
*.po
!django.po
!django.mo
!djangojs.po
!djangojs.mo
conf/locale/en/LC_MESSAGES/*.po conf/locale/en/LC_MESSAGES/*.po
!messages.po conf/locale/en/LC_MESSAGES/*.mo
### Remove when we have real Esperanto translations. For now, ignore conf/locale/messages.mo
### dummy Esperanto files.
conf/locale/eo/*
## Remove when we officially support these languages.
conf/locale/fr
conf/locale/ko_KR
### Testing artifacts ### Testing artifacts
.testids/ .testids/
...@@ -49,6 +49,7 @@ coverage.xml ...@@ -49,6 +49,7 @@ coverage.xml
cover/ cover/
cover_html/ cover_html/
reports/ reports/
jscover.log
jscover.log.* jscover.log.*
### Installation artifacts ### Installation artifacts
......
...@@ -13,9 +13,9 @@ source_file = conf/locale/en/LC_MESSAGES/django-studio.po ...@@ -13,9 +13,9 @@ source_file = conf/locale/en/LC_MESSAGES/django-studio.po
source_lang = en source_lang = en
type = PO type = PO
[edx-platform.djangojs] [edx-platform.djangojs-partial]
file_filter = conf/locale/<lang>/LC_MESSAGES/djangojs.po file_filter = conf/locale/<lang>/LC_MESSAGES/djangojs-partial.po
source_file = conf/locale/en/LC_MESSAGES/djangojs.po source_file = conf/locale/en/LC_MESSAGES/djangojs-partial.po
source_lang = en source_lang = en
type = PO type = PO
......
...@@ -105,4 +105,4 @@ Yihua Lou <supermouselyh@hotmail.com> ...@@ -105,4 +105,4 @@ Yihua Lou <supermouselyh@hotmail.com>
Andy Armstrong <andya@edx.org> Andy Armstrong <andya@edx.org>
Matt Drayer <mattdrayer@edx.org> Matt Drayer <mattdrayer@edx.org>
Cristian Salamea <cristian.salamea@iaen.edu.ec> Cristian Salamea <cristian.salamea@iaen.edu.ec>
Graham Lowe <graham.lowe@gmail.com>
...@@ -5,6 +5,8 @@ These are notable changes in edx-platform. This is a rolling list of changes, ...@@ -5,6 +5,8 @@ These are notable changes in edx-platform. This is a rolling list of changes,
in roughly chronological order, most recent first. Add your entries at or near in roughly chronological order, most recent first. Add your entries at or near
the top. Include a label indicating the component affected. the top. Include a label indicating the component affected.
Blades: Add role parameter to LTI. BLD-583.
Blades: Bugfix "In Firefox YouTube video with start time plays from 00:00:00". Blades: Bugfix "In Firefox YouTube video with start time plays from 00:00:00".
BLD-708. BLD-708.
...@@ -12,6 +14,14 @@ Blades: Fix bug when image response in Firefox does not retain input. BLD-711. ...@@ -12,6 +14,14 @@ Blades: Fix bug when image response in Firefox does not retain input. BLD-711.
Blades: Give numerical response tolerance as a range. BLD-25. Blades: Give numerical response tolerance as a range. BLD-25.
Common: Add a utility app for building databased-backed configuration
for specific application features. Includes admin site customization
for easier administration and tracking.
Common: Add the ability to dark-launch site translations. These languages
will be unavailable to users except through the use of a specific query
parameter.
Blades: Allow user with BetaTester role correctly use LTI. BLD-641. Blades: Allow user with BetaTester role correctly use LTI. BLD-641.
Blades: Video player persist speed preferences between videos. BLD-237. Blades: Video player persist speed preferences between videos. BLD-237.
...@@ -323,6 +333,8 @@ assessors to edit the original submitter's work. ...@@ -323,6 +333,8 @@ assessors to edit the original submitter's work.
LMS: Fixed a bug that caused links from forum user profile pages to LMS: Fixed a bug that caused links from forum user profile pages to
threads to lead to 404s if the course id contained a '-' character. threads to lead to 404s if the course id contained a '-' character.
Studio/LMS: Add password policy enforcement to new account creation
Studio/LMS: Added ability to set due date formatting through Studio's Advanced Studio/LMS: Added ability to set due date formatting through Studio's Advanced
Settings. The key is due_date_display_format, and the value should be a format Settings. The key is due_date_display_format, and the value should be a format
supported by Python's strftime function. supported by Python's strftime function.
......
...@@ -186,7 +186,7 @@ By opening up a pull request, we expect the following things: ...@@ -186,7 +186,7 @@ By opening up a pull request, we expect the following things:
unable to participate in the review process. unable to participate in the review process.
3. If you have questions, you will ask them by either commenting on the pull 3. If you have questions, you will ask them by either commenting on the pull
request or asking us in IRC or on the mailing list. request or asking us in IRC or on the mailing list.
4. If you do not respond to comments on your pull request within 7 days, we 4. If you do not respond to comments on your pull request within 7 days, we
will close it. You are welcome to re-open it when you are ready to engage. will close it. You are welcome to re-open it when you are ready to engage.
...@@ -239,7 +239,7 @@ generated. Click on the "View Reports" link on your pull request to be brought ...@@ -239,7 +239,7 @@ generated. Click on the "View Reports" link on your pull request to be brought
to the Jenkins report page. In a column on the left side of the page are a few to the Jenkins report page. In a column on the left side of the page are a few
links, including "Diff Coverage Report" and "Diff Quality Report". View each of links, including "Diff Coverage Report" and "Diff Quality Report". View each of
these reports (making note that the Diff Quality report has two tabs - one for these reports (making note that the Diff Quality report has two tabs - one for
pep8, and one for Pylint). pep8, and one for Pylint).
Make sure your quality coverage is 100% and your test coverage is at least 95%. Make sure your quality coverage is 100% and your test coverage is at least 95%.
Adjust your code appropriately if these metrics are not high enough. Be sure to Adjust your code appropriately if these metrics are not high enough. Be sure to
...@@ -307,7 +307,7 @@ commits, and comments. ...@@ -307,7 +307,7 @@ commits, and comments.
.. _individual contributor agreement: http://code.edx.org/individual-contributor-agreement.pdf .. _individual contributor agreement: http://code.edx.org/individual-contributor-agreement.pdf
.. _edx-platform testing documentation: https://github.com/edx/edx-platform/blob/master/docs/internal/testing.md .. _edx-platform testing documentation: https://github.com/edx/edx-platform/blob/master/docs/en_us/internal/testing.md
.. _mailing list: https://groups.google.com/forum/#!forum/edx-code .. _mailing list: https://groups.google.com/forum/#!forum/edx-code
.. _IRC channel: http://www.irchelp.org/irchelp/new2irc.html .. _IRC channel: http://www.irchelp.org/irchelp/new2irc.html
.. _pull request 1322: https://github.com/edx/edx-platform/pull/1322 .. _pull request 1322: https://github.com/edx/edx-platform/pull/1322
......
import ConfigParser
from django.conf import settings
config_file = open(settings.REPO_ROOT / "docs" / "config.ini")
config = ConfigParser.ConfigParser()
config.readfp(config_file)
def doc_url(request):
# in the future, we will detect the locale; for now, we will
# hardcode en_us, since we only have English documentation
locale = "en_us"
def get_doc_url(token):
try:
return config.get(locale, token)
except ConfigParser.NoOptionError:
return config.get(locale, "default")
return {"doc_url": get_doc_url}
...@@ -112,3 +112,19 @@ Feature: CMS.Component Adding ...@@ -112,3 +112,19 @@ Feature: CMS.Component Adding
Then I see a Problem component with display name "Blank Common Problem" in position "0" Then I see a Problem component with display name "Blank Common Problem" in position "0"
And I see a Problem component with display name "Duplicate of 'Blank Common Problem'" in position "1" And I see a Problem component with display name "Duplicate of 'Blank Common Problem'" in position "1"
And I see a Problem component with display name "Multiple Choice" in position "2" And I see a Problem component with display name "Multiple Choice" in position "2"
Scenario: I can set the display name of a component
Given I am in Studio editing a new unit
When I add a "Text" "HTML" component
Then I see the display name is "Text"
When I change the display name to "I'm the Cuddliest!"
Then I see the display name is "I'm the Cuddliest!"
Scenario: If a component has no display name, the category is displayed
Given I am in Studio editing a new unit
When I add a "Blank Advanced Problem" "Advanced Problem" component
Then I see the display name is "Blank Advanced Problem"
When I change the display name to ""
Then I see the display name is "problem"
When I unset the display name
Then I see the display name is "Blank Advanced Problem"
...@@ -8,6 +8,8 @@ ...@@ -8,6 +8,8 @@
from lettuce import world, step from lettuce import world, step
from nose.tools import assert_true, assert_in # pylint: disable=E0611 from nose.tools import assert_true, assert_in # pylint: disable=E0611
DISPLAY_NAME = "Display Name"
@step(u'I add this type of single step component:$') @step(u'I add this type of single step component:$')
def add_a_single_step_component(step): def add_a_single_step_component(step):
...@@ -154,3 +156,24 @@ def see_component_in_position(step, display_name, index): ...@@ -154,3 +156,24 @@ def see_component_in_position(step, display_name, index):
return world.css_text(component_css, int(index)).startswith(display_name.upper()) return world.css_text(component_css, int(index)).startswith(display_name.upper())
world.wait_for(find_problem, timeout_msg='Did not find the duplicated problem') world.wait_for(find_problem, timeout_msg='Did not find the duplicated problem')
@step(u'I see the display name is "([^"]*)"')
def check_component_display_name(step, display_name):
label = world.css_text(".component-header")
assert display_name == label
@step(u'I change the display name to "([^"]*)"')
def change_display_name(step, display_name):
world.edit_component_and_select_settings()
index = world.get_setting_entry_index(DISPLAY_NAME)
world.set_field_value(index, display_name)
world.save_component(step)
@step(u'I unset the display name')
def unset_display_name(step):
world.edit_component_and_select_settings()
world.revert_setting_entry(DISPLAY_NAME)
world.save_component(step)
...@@ -5,6 +5,7 @@ from lettuce import world ...@@ -5,6 +5,7 @@ from lettuce import world
from nose.tools import assert_equal, assert_in # pylint: disable=E0611 from nose.tools import assert_equal, assert_in # pylint: disable=E0611
from terrain.steps import reload_the_page from terrain.steps import reload_the_page
from common import type_in_codemirror from common import type_in_codemirror
from selenium.webdriver.common.keys import Keys
@world.absorb @world.absorb
...@@ -219,3 +220,18 @@ def get_setting_entry_index(label): ...@@ -219,3 +220,18 @@ def get_setting_entry_index(label):
return index return index
return None return None
return world.retry_on_exception(get_index) return world.retry_on_exception(get_index)
@world.absorb
def set_field_value(index, value):
"""
Set the field to the specified value.
Note: we cannot use css_fill here because the value is not set
until after you move away from that field.
Instead we will find the element, set its value, then hit the Tab key
to get to the next field.
"""
elem = world.css_find('div.wrapper-comp-setting input.setting-input')[index]
elem.value = value
elem.type(Keys.TAB)
...@@ -7,7 +7,6 @@ from nose.tools import assert_equal, assert_true # pylint: disable=E0611 ...@@ -7,7 +7,6 @@ from nose.tools import assert_equal, assert_true # pylint: disable=E0611
from common import type_in_codemirror, open_new_course from common import type_in_codemirror, open_new_course
from advanced_settings import change_value from advanced_settings import change_value
from course_import import import_file, go_to_import from course_import import import_file, go_to_import
from selenium.webdriver.common.keys import Keys
DISPLAY_NAME = "Display Name" DISPLAY_NAME = "Display Name"
MAXIMUM_ATTEMPTS = "Maximum Attempts" MAXIMUM_ATTEMPTS = "Maximum Attempts"
...@@ -53,7 +52,7 @@ def i_can_modify_the_display_name(_step): ...@@ -53,7 +52,7 @@ def i_can_modify_the_display_name(_step):
# Verifying that the display name can be a string containing a floating point value # Verifying that the display name can be a string containing a floating point value
# (to confirm that we don't throw an error because it is of the wrong type). # (to confirm that we don't throw an error because it is of the wrong type).
index = world.get_setting_entry_index(DISPLAY_NAME) index = world.get_setting_entry_index(DISPLAY_NAME)
set_field_value(index, '3.4') world.set_field_value(index, '3.4')
verify_modified_display_name() verify_modified_display_name()
...@@ -66,7 +65,7 @@ def my_display_name_change_is_persisted_on_save(step): ...@@ -66,7 +65,7 @@ def my_display_name_change_is_persisted_on_save(step):
@step('I can specify special characters in the display name') @step('I can specify special characters in the display name')
def i_can_modify_the_display_name_with_special_chars(_step): def i_can_modify_the_display_name_with_special_chars(_step):
index = world.get_setting_entry_index(DISPLAY_NAME) index = world.get_setting_entry_index(DISPLAY_NAME)
set_field_value(index, "updated ' \" &") world.set_field_value(index, "updated ' \" &")
verify_modified_display_name_with_special_chars() verify_modified_display_name_with_special_chars()
...@@ -141,7 +140,7 @@ def set_the_max_attempts(step, max_attempts_set): ...@@ -141,7 +140,7 @@ def set_the_max_attempts(step, max_attempts_set):
# on firefox with selenium, the behaviour is different. # on firefox with selenium, the behaviour is different.
# eg 2.34 displays as 2.34 and is persisted as 2 # eg 2.34 displays as 2.34 and is persisted as 2
index = world.get_setting_entry_index(MAXIMUM_ATTEMPTS) index = world.get_setting_entry_index(MAXIMUM_ATTEMPTS)
set_field_value(index, max_attempts_set) world.set_field_value(index, max_attempts_set)
world.save_component_and_reopen(step) world.save_component_and_reopen(step)
value = world.css_value('input.setting-input', index=index) value = world.css_value('input.setting-input', index=index)
assert value != "", "max attempts is blank" assert value != "", "max attempts is blank"
...@@ -282,23 +281,9 @@ def verify_unset_display_name(): ...@@ -282,23 +281,9 @@ def verify_unset_display_name():
world.verify_setting_entry(world.get_setting_entry(DISPLAY_NAME), DISPLAY_NAME, 'Blank Advanced Problem', False) world.verify_setting_entry(world.get_setting_entry(DISPLAY_NAME), DISPLAY_NAME, 'Blank Advanced Problem', False)
def set_field_value(index, value):
"""
Set the field to the specified value.
Note: we cannot use css_fill here because the value is not set
until after you move away from that field.
Instead we will find the element, set its value, then hit the Tab key
to get to the next field.
"""
elem = world.css_find('div.wrapper-comp-setting input.setting-input')[index]
elem.value = value
elem.type(Keys.TAB)
def set_weight(weight): def set_weight(weight):
index = world.get_setting_entry_index(PROBLEM_WEIGHT) index = world.get_setting_entry_index(PROBLEM_WEIGHT)
set_field_value(index, weight) world.set_field_value(index, weight)
def open_high_level_source(): def open_high_level_source():
......
...@@ -146,20 +146,21 @@ Feature: CMS.Transcripts ...@@ -146,20 +146,21 @@ Feature: CMS.Transcripts
Then I see status message "found" Then I see status message "found"
And I see value "t_not_exist" in the field "HTML5 Transcript" And I see value "t_not_exist" in the field "HTML5 Transcript"
# Disabled 1/29/14 due to flakiness observed in master
#10 #10
Scenario: User sets youtube_id w/o local but with server subs and one html5 link w/o subs #Scenario: User sets youtube_id w/o local but with server subs and one html5 link w/o subs
Given I have created a Video component # Given I have created a Video component
And I edit the component # And I edit the component
#
And I enter a "http://youtu.be/t__eq_exist" source to field number 1 # And I enter a "http://youtu.be/t__eq_exist" source to field number 1
Then I see status message "not found" # Then I see status message "not found"
And I see button "import" # And I see button "import"
And I click transcript button "import" # And I click transcript button "import"
Then I see status message "found" # Then I see status message "found"
#
And I enter a "t_not_exist.mp4" source to field number 2 # And I enter a "t_not_exist.mp4" source to field number 2
Then I see status message "found" # Then I see status message "found"
And I see value "t__eq_exist" in the field "HTML5 Transcript" # And I see value "t__eq_exist" in the field "HTML5 Transcript"
#11 #11
Scenario: User sets youtube_id w/o local but with server subs and one html5 link w/o transcripts w/o import action, then another one html5 link w/o transcripts Scenario: User sets youtube_id w/o local but with server subs and one html5 link w/o transcripts w/o import action, then another one html5 link w/o transcripts
......
"""
Script for converting a tar.gz file representing an exported course
to the archive format used by a different version of export.
Sample invocation: ./manage.py export_convert_format mycourse.tar.gz ~/newformat/
"""
import os
from path import path
from django.core.management.base import BaseCommand, CommandError
from tempfile import mkdtemp
import tarfile
import shutil
from extract_tar import safetar_extractall
from xmodule.modulestore.xml_exporter import convert_between_versions
class Command(BaseCommand):
"""
Convert between export formats.
"""
help = 'Convert between versions 0 and 1 of the course export format'
args = '<tar.gz archive file> <output path>'
def handle(self, *args, **options):
"Execute the command"
if len(args) != 2:
raise CommandError("export requires two arguments: <tar.gz file> <output path>")
source_archive = args[0]
output_path = args[1]
# Create temp directories to extract the source and create the target archive.
temp_source_dir = mkdtemp()
temp_target_dir = mkdtemp()
try:
extract_source(source_archive, temp_source_dir)
desired_version = convert_between_versions(temp_source_dir, temp_target_dir)
# New zip up the target directory.
parts = os.path.basename(source_archive).split('.')
archive_name = path(output_path) / "{source_name}_version_{desired_version}.tar.gz".format(
source_name=parts[0], desired_version=desired_version
)
with open(archive_name, "w"):
tar_file = tarfile.open(archive_name, mode='w:gz')
try:
for item in os.listdir(temp_target_dir):
tar_file.add(path(temp_target_dir) / item, arcname=item)
finally:
tar_file.close()
print("Created archive {0}".format(archive_name))
except ValueError as err:
raise CommandError(err)
finally:
shutil.rmtree(temp_source_dir)
shutil.rmtree(temp_target_dir)
def extract_source(source_archive, target):
"""
Extract the archive into the given target directory.
"""
with tarfile.open(source_archive) as tar_file:
safetar_extractall(tar_file, target)
"""
Django management command to migrate a course from the old Mongo modulestore
to the new split-Mongo modulestore.
"""
from django.core.management.base import BaseCommand, CommandError
from django.contrib.auth.models import User
from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.split_migrator import SplitMigrator
from xmodule.modulestore import InvalidLocationError
from xmodule.modulestore.django import loc_mapper
def user_from_str(identifier):
"""
Return a user identified by the given string. The string could be an email
address, or a stringified integer corresponding to the ID of the user in
the database. If no user could be found, a User.DoesNotExist exception
will be raised.
"""
try:
user_id = int(identifier)
except ValueError:
return User.objects.get(email=identifier)
else:
return User.objects.get(id=user_id)
class Command(BaseCommand):
"Migrate a course from old-Mongo to split-Mongo"
help = "Migrate a course from old-Mongo to split-Mongo"
args = "location email <locator>"
def parse_args(self, *args):
"""
Return a three-tuple of (location, user, locator_string).
If the user didn't specify a locator string, the third return value
will be None.
"""
if len(args) < 2:
raise CommandError(
"migrate_to_split requires at least two arguments: "
"a location and a user identifier (email or ID)"
)
try:
location = Location(args[0])
except InvalidLocationError:
raise CommandError("Invalid location string {}".format(args[0]))
try:
user = user_from_str(args[1])
except User.DoesNotExist:
raise CommandError("No user found identified by {}".format(args[1]))
try:
package_id = args[2]
except IndexError:
package_id = None
return location, user, package_id
def handle(self, *args, **options):
location, user, package_id = self.parse_args(*args)
migrator = SplitMigrator(
draft_modulestore=modulestore('default'),
direct_modulestore=modulestore('direct'),
split_modulestore=modulestore('split'),
loc_mapper=loc_mapper(),
)
migrator.migrate_mongo_course(location, user, package_id)
"""
Django management command to rollback a migration to split. The way to do this
is to delete the course from the split mongo datastore.
"""
from django.core.management.base import BaseCommand, CommandError
from xmodule.modulestore.django import modulestore, loc_mapper
from xmodule.modulestore.exceptions import ItemNotFoundError, InsufficientSpecificationError
from xmodule.modulestore.locator import CourseLocator
class Command(BaseCommand):
"Rollback a course that was migrated to the split Mongo datastore"
help = "Rollback a course that was migrated to the split Mongo datastore"
args = "locator"
def handle(self, *args, **options):
if len(args) < 1:
raise CommandError(
"rollback_split_course requires at least one argument (locator)"
)
try:
locator = CourseLocator(url=args[0])
except ValueError:
raise CommandError("Invalid locator string {}".format(args[0]))
location = loc_mapper().translate_locator_to_location(locator, get_course=True)
if not location:
raise CommandError(
"This course does not exist in the old Mongo store. "
"This command is designed to rollback a course, not delete "
"it entirely."
)
old_mongo_course = modulestore('direct').get_item(location)
if not old_mongo_course:
raise CommandError(
"This course does not exist in the old Mongo store. "
"This command is designed to rollback a course, not delete "
"it entirely."
)
try:
modulestore('split').delete_course(locator.package_id)
except ItemNotFoundError:
raise CommandError("No course found with locator {}".format(locator))
print(
'Course rolled back successfully. To delete this course entirely, '
'call the "delete_course" management command.'
)
"""
Test for export_convert_format.
"""
from unittest import TestCase
from django.core.management import call_command, CommandError
from tempfile import mkdtemp
import shutil
from path import path
from contentstore.management.commands.export_convert_format import Command, extract_source
from xmodule.tests.helpers import directories_equal
class ConvertExportFormat(TestCase):
"""
Tests converting between export formats.
"""
def setUp(self):
""" Common setup. """
self.temp_dir = mkdtemp()
self.data_dir = path(__file__).realpath().parent / 'data'
self.version0 = self.data_dir / "Version0_drafts.tar.gz"
self.version1 = self.data_dir / "Version1_drafts.tar.gz"
self.command = Command()
def tearDown(self):
""" Common cleanup. """
shutil.rmtree(self.temp_dir)
def test_no_args(self):
""" Test error condition of no arguments. """
errstring = "export requires two arguments"
with self.assertRaisesRegexp(CommandError, errstring):
self.command.handle()
def test_version1_archive(self):
"""
Smoke test for creating a version 1 archive from a version 0.
"""
call_command('export_convert_format', self.version0, self.temp_dir)
output = path(self.temp_dir) / 'Version0_drafts_version_1.tar.gz'
self.assertTrue(self._verify_archive_equality(output, self.version1))
def test_version0_archive(self):
"""
Smoke test for creating a version 0 archive from a version 1.
"""
call_command('export_convert_format', self.version1, self.temp_dir)
output = path(self.temp_dir) / 'Version1_drafts_version_0.tar.gz'
self.assertTrue(self._verify_archive_equality(output, self.version0))
def _verify_archive_equality(self, file1, file2):
"""
Helper function for determining if 2 archives are equal.
"""
temp_dir_1 = mkdtemp()
temp_dir_2 = mkdtemp()
try:
extract_source(file1, temp_dir_1)
extract_source(file2, temp_dir_2)
return directories_equal(temp_dir_1, temp_dir_2)
finally:
shutil.rmtree(temp_dir_1)
shutil.rmtree(temp_dir_2)
"""
Unittests for migrating a course to split mongo
"""
import unittest
from django.contrib.auth.models import User
from django.core.management import CommandError, call_command
from django.test.utils import override_settings
from contentstore.management.commands.migrate_to_split import Command
from contentstore.tests.modulestore_config import TEST_MODULESTORE
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.django import modulestore, loc_mapper
from xmodule.modulestore.locator import CourseLocator
# pylint: disable=E1101
class TestArgParsing(unittest.TestCase):
"""
Tests for parsing arguments for the `migrate_to_split` management command
"""
def setUp(self):
self.command = Command()
def test_no_args(self):
errstring = "migrate_to_split requires at least two arguments"
with self.assertRaisesRegexp(CommandError, errstring):
self.command.handle()
def test_invalid_location(self):
errstring = "Invalid location string"
with self.assertRaisesRegexp(CommandError, errstring):
self.command.handle("foo", "bar")
def test_nonexistant_user_id(self):
errstring = "No user found identified by 99"
with self.assertRaisesRegexp(CommandError, errstring):
self.command.handle("i4x://org/course/category/name", "99")
def test_nonexistant_user_email(self):
errstring = "No user found identified by fake@example.com"
with self.assertRaisesRegexp(CommandError, errstring):
self.command.handle("i4x://org/course/category/name", "fake@example.com")
@override_settings(MODULESTORE=TEST_MODULESTORE)
class TestMigrateToSplit(ModuleStoreTestCase):
"""
Unit tests for migrating a course from old mongo to split mongo
"""
def setUp(self):
super(TestMigrateToSplit, self).setUp()
uname = 'testuser'
email = 'test+courses@edx.org'
password = 'foo'
self.user = User.objects.create_user(uname, email, password)
self.course = CourseFactory()
def test_user_email(self):
call_command(
"migrate_to_split",
str(self.course.location),
str(self.user.email),
)
locator = loc_mapper().translate_location(self.course.id, self.course.location)
course_from_split = modulestore('split').get_course(locator)
self.assertIsNotNone(course_from_split)
def test_user_id(self):
call_command(
"migrate_to_split",
str(self.course.location),
str(self.user.id),
)
locator = loc_mapper().translate_location(self.course.id, self.course.location)
course_from_split = modulestore('split').get_course(locator)
self.assertIsNotNone(course_from_split)
def test_locator_string(self):
call_command(
"migrate_to_split",
str(self.course.location),
str(self.user.id),
"org.dept.name.run",
)
locator = CourseLocator(package_id="org.dept.name.run", branch="published")
course_from_split = modulestore('split').get_course(locator)
self.assertIsNotNone(course_from_split)
"""
Unittests for deleting a split mongo course
"""
import unittest
from StringIO import StringIO
from mock import patch
from django.contrib.auth.models import User
from django.core.management import CommandError, call_command
from django.test.utils import override_settings
from contentstore.management.commands.rollback_split_course import Command
from contentstore.tests.modulestore_config import TEST_MODULESTORE
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.persistent_factories import PersistentCourseFactory
from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.django import modulestore, loc_mapper
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore.split_migrator import SplitMigrator
# pylint: disable=E1101
class TestArgParsing(unittest.TestCase):
"""
Tests for parsing arguments for the `rollback_split_course` management command
"""
def setUp(self):
self.command = Command()
def test_no_args(self):
errstring = "rollback_split_course requires at least one argument"
with self.assertRaisesRegexp(CommandError, errstring):
self.command.handle()
def test_invalid_locator(self):
errstring = "Invalid locator string !?!"
with self.assertRaisesRegexp(CommandError, errstring):
self.command.handle("!?!")
@override_settings(MODULESTORE=TEST_MODULESTORE)
class TestRollbackSplitCourseNoOldMongo(ModuleStoreTestCase):
"""
Unit tests for rolling back a split-mongo course from command line,
where the course doesn't exist in the old mongo store
"""
def setUp(self):
super(TestRollbackSplitCourseNoOldMongo, self).setUp()
self.course = PersistentCourseFactory()
def test_no_old_course(self):
locator = self.course.location
errstring = "course does not exist in the old Mongo store"
with self.assertRaisesRegexp(CommandError, errstring):
Command().handle(str(locator))
@override_settings(MODULESTORE=TEST_MODULESTORE)
class TestRollbackSplitCourseNoSplitMongo(ModuleStoreTestCase):
"""
Unit tests for rolling back a split-mongo course from command line,
where the course doesn't exist in the split mongo store
"""
def setUp(self):
super(TestRollbackSplitCourseNoSplitMongo, self).setUp()
self.old_course = CourseFactory()
def test_nonexistent_locator(self):
locator = loc_mapper().translate_location(self.old_course.id, self.old_course.location)
errstring = "No course found with locator"
with self.assertRaisesRegexp(CommandError, errstring):
Command().handle(str(locator))
@override_settings(MODULESTORE=TEST_MODULESTORE)
class TestRollbackSplitCourse(ModuleStoreTestCase):
"""
Unit tests for rolling back a split-mongo course from command line
"""
def setUp(self):
super(TestRollbackSplitCourse, self).setUp()
self.old_course = CourseFactory()
uname = 'testuser'
email = 'test+courses@edx.org'
password = 'foo'
self.user = User.objects.create_user(uname, email, password)
# migrate old course to split
migrator = SplitMigrator(
draft_modulestore=modulestore('default'),
direct_modulestore=modulestore('direct'),
split_modulestore=modulestore('split'),
loc_mapper=loc_mapper(),
)
migrator.migrate_mongo_course(self.old_course.location, self.user)
locator = loc_mapper().translate_location(self.old_course.id, self.old_course.location)
self.course = modulestore('split').get_course(locator)
@patch("sys.stdout", new_callable=StringIO)
def test_happy_path(self, mock_stdout):
locator = self.course.location
call_command(
"rollback_split_course",
str(locator),
)
with self.assertRaises(ItemNotFoundError):
modulestore('split').get_course(locator)
self.assertIn("Course rolled back successfully", mock_stdout.getvalue())
"""
Tests access.py
"""
from django.test import TestCase
from django.contrib.auth.models import User
from xmodule.modulestore import Location
from student.roles import CourseInstructorRole, CourseStaffRole
from student.tests.factories import AdminFactory
from student.auth import add_users
from contentstore.views.access import get_user_role
class RolesTest(TestCase):
"""
Tests for user roles.
"""
def setUp(self):
""" Test case setup """
self.global_admin = AdminFactory()
self.instructor = User.objects.create_user('testinstructor', 'testinstructor+courses@edx.org', 'foo')
self.staff = User.objects.create_user('teststaff', 'teststaff+courses@edx.org', 'foo')
self.location = Location('i4x', 'mitX', '101', 'course', 'test')
def test_get_user_role_instructor(self):
"""
Verifies if user is instructor.
"""
add_users(self.global_admin, CourseInstructorRole(self.location), self.instructor)
self.assertEqual(
'instructor',
get_user_role(self.instructor, self.location, self.location.course_id)
)
def test_get_user_role_staff(self):
"""
Verifies if user is staff.
"""
add_users(self.global_admin, CourseStaffRole(self.location), self.staff)
self.assertEqual(
'staff',
get_user_role(self.staff, self.location, self.location.course_id)
)
...@@ -133,7 +133,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): ...@@ -133,7 +133,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
# just pick one vertical # just pick one vertical
descriptor = store.get_items(Location('i4x', 'edX', 'simple', 'vertical', None, None))[0] descriptor = store.get_items(Location('i4x', 'edX', 'simple', 'vertical', None, None))[0]
locator = loc_mapper().translate_location(course.location.course_id, descriptor.location, False, True) locator = loc_mapper().translate_location(course.location.course_id, descriptor.location, True, True)
resp = self.client.get_html(locator.url_reverse('unit')) resp = self.client.get_html(locator.url_reverse('unit'))
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
_test_no_locations(self, resp) _test_no_locations(self, resp)
...@@ -144,12 +144,11 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): ...@@ -144,12 +144,11 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
def test_advanced_components_in_edit_unit(self): def test_advanced_components_in_edit_unit(self):
# This could be made better, but for now let's just assert that we see the advanced modules mentioned in the page # This could be made better, but for now let's just assert that we see the advanced modules mentioned in the page
# response HTML # response HTML
self.check_components_on_page(ADVANCED_COMPONENT_TYPES, ['Word cloud', self.check_components_on_page(
'Annotation', ADVANCED_COMPONENT_TYPES,
'Text Annotation', ['Word cloud', 'Annotation', 'Text Annotation', 'Video Annotation',
'Video Annotation', 'Open Response Assessment', 'Peer Grading Interface'],
'Open Response Assessment', )
'Peer Grading Interface'])
def test_advanced_components_require_two_clicks(self): def test_advanced_components_require_two_clicks(self):
self.check_components_on_page(['word_cloud'], ['Word cloud']) self.check_components_on_page(['word_cloud'], ['Word cloud'])
...@@ -161,7 +160,8 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): ...@@ -161,7 +160,8 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
# just pick one vertical # just pick one vertical
descriptor = store.get_items(Location('i4x', 'edX', 'simple', 'vertical', None, None))[0] descriptor = store.get_items(Location('i4x', 'edX', 'simple', 'vertical', None, None))[0]
location = descriptor.location.replace(name='.' + descriptor.location.name) location = descriptor.location.replace(name='.' + descriptor.location.name)
locator = loc_mapper().translate_location(course_items[0].location.course_id, location, False, True) locator = loc_mapper().translate_location(
course_items[0].location.course_id, location, add_entry_if_missing=True)
resp = self.client.get_html(locator.url_reverse('unit')) resp = self.client.get_html(locator.url_reverse('unit'))
self.assertEqual(resp.status_code, 400) self.assertEqual(resp.status_code, 400)
...@@ -449,7 +449,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): ...@@ -449,7 +449,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
""" Returns the locator for a given tab. """ """ Returns the locator for a given tab. """
tab_location = 'i4x://edX/999/static_tab/{0}'.format(tab['url_slug']) tab_location = 'i4x://edX/999/static_tab/{0}'.format(tab['url_slug'])
return loc_mapper().translate_location( return loc_mapper().translate_location(
course.location.course_id, Location(tab_location), False, True course.location.course_id, Location(tab_location), True, True
) )
def _create_static_tabs(self): def _create_static_tabs(self):
...@@ -457,7 +457,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): ...@@ -457,7 +457,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
module_store = modulestore('direct') module_store = modulestore('direct')
CourseFactory.create(org='edX', course='999', display_name='Robot Super Course') CourseFactory.create(org='edX', course='999', display_name='Robot Super Course')
course_location = Location('i4x', 'edX', '999', 'course', 'Robot_Super_Course', None) course_location = Location('i4x', 'edX', '999', 'course', 'Robot_Super_Course', None)
new_location = loc_mapper().translate_location(course_location.course_id, course_location, False, True) new_location = loc_mapper().translate_location(course_location.course_id, course_location, True, True)
ItemFactory.create( ItemFactory.create(
parent_location=course_location, parent_location=course_location,
...@@ -512,7 +512,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): ...@@ -512,7 +512,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
# also try a custom response which will trigger the 'is this course in whitelist' logic # also try a custom response which will trigger the 'is this course in whitelist' logic
locator = loc_mapper().translate_location( locator = loc_mapper().translate_location(
course_items[0].location.course_id, location, False, True course_items[0].location.course_id, location, True, True
) )
resp = self.client.get_html(locator.url_reverse('xblock')) resp = self.client.get_html(locator.url_reverse('xblock'))
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
...@@ -534,7 +534,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): ...@@ -534,7 +534,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
# make sure the parent points to the child object which is to be deleted # make sure the parent points to the child object which is to be deleted
self.assertTrue(sequential.location.url() in chapter.children) self.assertTrue(sequential.location.url() in chapter.children)
location = loc_mapper().translate_location(course_location.course_id, sequential.location, False, True) location = loc_mapper().translate_location(course_location.course_id, sequential.location, True, True)
self.client.delete(location.url_reverse('xblock'), {'recurse': True, 'all_versions': True}) self.client.delete(location.url_reverse('xblock'), {'recurse': True, 'all_versions': True})
found = False found = False
...@@ -685,7 +685,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): ...@@ -685,7 +685,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
# go through the website to do the delete, since the soft-delete logic is in the view # go through the website to do the delete, since the soft-delete logic is in the view
course = course_items[0] course = course_items[0]
location = loc_mapper().translate_location(course.location.course_id, course.location, False, True) location = loc_mapper().translate_location(course.location.course_id, course.location, True, True)
url = location.url_reverse('assets/', '/c4x/edX/toy/asset/sample_static.txt') url = location.url_reverse('assets/', '/c4x/edX/toy/asset/sample_static.txt')
resp = self.client.delete(url) resp = self.client.delete(url)
self.assertEqual(resp.status_code, 204) self.assertEqual(resp.status_code, 204)
...@@ -1062,7 +1062,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): ...@@ -1062,7 +1062,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
) )
# Unit test fails in Jenkins without this. # Unit test fails in Jenkins without this.
loc_mapper().translate_location(course_location.course_id, course_location, False, True) loc_mapper().translate_location(course_location.course_id, course_location, True, True)
items = module_store.get_items(stub_location.replace(category='vertical', name=None)) items = module_store.get_items(stub_location.replace(category='vertical', name=None))
self._check_verticals(items, course_location.course_id) self._check_verticals(items, course_location.course_id)
...@@ -1353,7 +1353,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): ...@@ -1353,7 +1353,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
# Assert is here to make sure that the course being tested actually has verticals (units) to check. # Assert is here to make sure that the course being tested actually has verticals (units) to check.
self.assertGreater(len(items), 0) self.assertGreater(len(items), 0)
for descriptor in items: for descriptor in items:
unit_locator = loc_mapper().translate_location(course_id, descriptor.location, False, True) unit_locator = loc_mapper().translate_location(course_id, descriptor.location, True, True)
resp = self.client.get_html(unit_locator.url_reverse('unit')) resp = self.client.get_html(unit_locator.url_reverse('unit'))
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
_test_no_locations(self, resp) _test_no_locations(self, resp)
...@@ -1645,7 +1645,7 @@ class ContentStoreTest(ModuleStoreTestCase): ...@@ -1645,7 +1645,7 @@ class ContentStoreTest(ModuleStoreTestCase):
import_from_xml(modulestore('direct'), 'common/test/data/', ['simple']) import_from_xml(modulestore('direct'), 'common/test/data/', ['simple'])
loc = Location(['i4x', 'edX', 'simple', 'course', '2012_Fall', None]) loc = Location(['i4x', 'edX', 'simple', 'course', '2012_Fall', None])
new_location = loc_mapper().translate_location(loc.course_id, loc, False, True) new_location = loc_mapper().translate_location(loc.course_id, loc, True, True)
resp = self._show_course_overview(loc) resp = self._show_course_overview(loc)
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
...@@ -1666,14 +1666,14 @@ class ContentStoreTest(ModuleStoreTestCase): ...@@ -1666,14 +1666,14 @@ class ContentStoreTest(ModuleStoreTestCase):
# go look at a subsection page # go look at a subsection page
subsection_location = loc.replace(category='sequential', name='test_sequence') subsection_location = loc.replace(category='sequential', name='test_sequence')
subsection_locator = loc_mapper().translate_location(loc.course_id, subsection_location, False, True) subsection_locator = loc_mapper().translate_location(loc.course_id, subsection_location, True, True)
resp = self.client.get_html(subsection_locator.url_reverse('subsection')) resp = self.client.get_html(subsection_locator.url_reverse('subsection'))
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
_test_no_locations(self, resp) _test_no_locations(self, resp)
# go look at the Edit page # go look at the Edit page
unit_location = loc.replace(category='vertical', name='test_vertical') unit_location = loc.replace(category='vertical', name='test_vertical')
unit_locator = loc_mapper().translate_location(loc.course_id, unit_location, False, True) unit_locator = loc_mapper().translate_location(loc.course_id, unit_location, True, True)
resp = self.client.get_html(unit_locator.url_reverse('unit')) resp = self.client.get_html(unit_locator.url_reverse('unit'))
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
_test_no_locations(self, resp) _test_no_locations(self, resp)
...@@ -1681,7 +1681,7 @@ class ContentStoreTest(ModuleStoreTestCase): ...@@ -1681,7 +1681,7 @@ class ContentStoreTest(ModuleStoreTestCase):
def delete_item(category, name): def delete_item(category, name):
""" Helper method for testing the deletion of an xblock item. """ """ Helper method for testing the deletion of an xblock item. """
del_loc = loc.replace(category=category, name=name) del_loc = loc.replace(category=category, name=name)
del_location = loc_mapper().translate_location(loc.course_id, del_loc, False, True) del_location = loc_mapper().translate_location(loc.course_id, del_loc, True, True)
resp = self.client.delete(del_location.url_reverse('xblock')) resp = self.client.delete(del_location.url_reverse('xblock'))
self.assertEqual(resp.status_code, 204) self.assertEqual(resp.status_code, 204)
_test_no_locations(self, resp, status_code=204, html=False) _test_no_locations(self, resp, status_code=204, html=False)
...@@ -1883,7 +1883,7 @@ class ContentStoreTest(ModuleStoreTestCase): ...@@ -1883,7 +1883,7 @@ class ContentStoreTest(ModuleStoreTestCase):
""" """
Show the course overview page. Show the course overview page.
""" """
new_location = loc_mapper().translate_location(location.course_id, location, False, True) new_location = loc_mapper().translate_location(location.course_id, location, True, True)
resp = self.client.get_html(new_location.url_reverse('course/', '')) resp = self.client.get_html(new_location.url_reverse('course/', ''))
_test_no_locations(self, resp) _test_no_locations(self, resp)
return resp return resp
...@@ -1998,7 +1998,7 @@ def _course_factory_create_course(): ...@@ -1998,7 +1998,7 @@ def _course_factory_create_course():
Creates a course via the CourseFactory and returns the locator for it. Creates a course via the CourseFactory and returns the locator for it.
""" """
course = CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course') course = CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course')
return loc_mapper().translate_location(course.location.course_id, course.location, False, True) return loc_mapper().translate_location(course.location.course_id, course.location, True, True)
def _get_course_id(test_course_data): def _get_course_id(test_course_data):
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
This test file will test registration, login, activation, and session activity timeouts This test file will test registration, login, activation, and session activity timeouts
""" """
import time import time
import mock
from django.test.utils import override_settings from django.test.utils import override_settings
from django.core.cache import cache from django.core.cache import cache
...@@ -16,6 +17,7 @@ from contentstore.tests.modulestore_config import TEST_MODULESTORE ...@@ -16,6 +17,7 @@ from contentstore.tests.modulestore_config import TEST_MODULESTORE
import datetime import datetime
from pytz import UTC from pytz import UTC
from freezegun import freeze_time
@override_settings(MODULESTORE=TEST_MODULESTORE) @override_settings(MODULESTORE=TEST_MODULESTORE)
class ContentStoreTestCase(ModuleStoreTestCase): class ContentStoreTestCase(ModuleStoreTestCase):
...@@ -109,7 +111,7 @@ class AuthTestCase(ContentStoreTestCase): ...@@ -109,7 +111,7 @@ class AuthTestCase(ContentStoreTestCase):
def test_create_account_errors(self): def test_create_account_errors(self):
# No post data -- should fail # No post data -- should fail
resp = self.client.post('/create_account', {}) resp = self.client.post('/create_account', {})
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 400)
data = parse_json(resp) data = parse_json(resp)
self.assertEqual(data['success'], False) self.assertEqual(data['success'], False)
...@@ -142,6 +144,53 @@ class AuthTestCase(ContentStoreTestCase): ...@@ -142,6 +144,53 @@ class AuthTestCase(ContentStoreTestCase):
self.assertFalse(data['success']) self.assertFalse(data['success'])
self.assertIn('Too many failed login attempts.', data['value']) self.assertIn('Too many failed login attempts.', data['value'])
@override_settings(MAX_FAILED_LOGIN_ATTEMPTS_ALLOWED=3)
@override_settings(MAX_FAILED_LOGIN_ATTEMPTS_LOCKOUT_PERIOD_SECS=2)
def test_excessive_login_failures(self):
# try logging in 3 times, the account should get locked for 3 seconds
# note we want to keep the lockout time short, so we don't slow down the tests
with mock.patch.dict('django.conf.settings.FEATURES', {'ENABLE_MAX_FAILED_LOGIN_ATTEMPTS': True}):
self.create_account(self.username, self.email, self.pw)
self.activate_user(self.email)
for i in xrange(3):
resp = self._login(self.email, 'wrong_password{0}'.format(i))
self.assertEqual(resp.status_code, 200)
data = parse_json(resp)
self.assertFalse(data['success'])
self.assertIn(
'Email or password is incorrect.',
data['value']
)
# now the account should be locked
resp = self._login(self.email, 'wrong_password')
self.assertEqual(resp.status_code, 200)
data = parse_json(resp)
self.assertFalse(data['success'])
self.assertIn(
'This account has been temporarily locked due to excessive login failures. Try again later.',
data['value']
)
with freeze_time('2100-01-01'):
self.login(self.email, self.pw)
# make sure the failed attempt counter gets reset on successful login
resp = self._login(self.email, 'wrong_password')
self.assertEqual(resp.status_code, 200)
data = parse_json(resp)
self.assertFalse(data['success'])
# account should not be locked out after just one attempt
self.login(self.email, self.pw)
# do one more login when there is no bad login counter row at all in the database to
# test the "ObjectNotFound" case
self.login(self.email, self.pw)
def test_login_link_on_activation_age(self): def test_login_link_on_activation_age(self):
self.create_account(self.username, self.email, self.pw) self.create_account(self.username, self.email, self.pw)
# we want to test the rendering of the activation page when the user isn't logged in # we want to test the rendering of the activation page when the user isn't logged in
......
...@@ -143,7 +143,7 @@ def get_lms_link_for_item(location, preview=False, course_id=None): ...@@ -143,7 +143,7 @@ def get_lms_link_for_item(location, preview=False, course_id=None):
else: else:
lms_base = settings.LMS_BASE lms_base = settings.LMS_BASE
lms_link = "//{lms_base}/courses/{course_id}/jump_to/{location}".format( lms_link = u"//{lms_base}/courses/{course_id}/jump_to/{location}".format(
lms_base=lms_base, lms_base=lms_base,
course_id=course_id, course_id=course_id,
location=Location(location) location=Location(location)
...@@ -179,7 +179,7 @@ def get_lms_link_for_about_page(location): ...@@ -179,7 +179,7 @@ def get_lms_link_for_about_page(location):
about_base = None about_base = None
if about_base is not None: if about_base is not None:
lms_link = "//{about_base_url}/courses/{course_id}/about".format( lms_link = u"//{about_base_url}/courses/{course_id}/about".format(
about_base_url=about_base, about_base_url=about_base,
course_id=Location(location).course_id course_id=Location(location).course_id
) )
......
from ..utils import get_course_location_for_item from ..utils import get_course_location_for_item
from xmodule.modulestore.locator import CourseLocator from xmodule.modulestore.locator import CourseLocator
from student.roles import CourseStaffRole, GlobalStaff from student.roles import CourseStaffRole, GlobalStaff, CourseInstructorRole
from student import auth from student import auth
...@@ -20,3 +20,17 @@ def has_course_access(user, location, role=CourseStaffRole): ...@@ -20,3 +20,17 @@ def has_course_access(user, location, role=CourseStaffRole):
# this can be expensive if location is not category=='course' # this can be expensive if location is not category=='course'
location = get_course_location_for_item(location) location = get_course_location_for_item(location)
return auth.has_access(user, role(location)) return auth.has_access(user, role(location))
def get_user_role(user, location, context):
"""
Return corresponding string if user has staff or instructor role in Studio.
This will not return student role because its purpose for using in Studio.
:param location: a descriptor.location
:param context: a course_id
"""
if auth.has_access(user, CourseInstructorRole(location, context)):
return 'instructor'
else:
return 'staff'
...@@ -267,8 +267,7 @@ def unit_handler(request, tag=None, package_id=None, branch=None, version_guid=N ...@@ -267,8 +267,7 @@ def unit_handler(request, tag=None, package_id=None, branch=None, version_guid=N
preview_lms_base = settings.FEATURES.get('PREVIEW_LMS_BASE') preview_lms_base = settings.FEATURES.get('PREVIEW_LMS_BASE')
preview_lms_link = ( preview_lms_link = (
'//{preview_lms_base}/courses/{org}/{course}/' u'//{preview_lms_base}/courses/{org}/{course}/{course_name}/courseware/{section}/{subsection}/{index}'
'{course_name}/courseware/{section}/{subsection}/{index}'
).format( ).format(
preview_lms_base=preview_lms_base, preview_lms_base=preview_lms_base,
lms_base=settings.LMS_BASE, lms_base=settings.LMS_BASE,
......
...@@ -251,7 +251,7 @@ def create_new_course(request): ...@@ -251,7 +251,7 @@ def create_new_course(request):
run = request.json.get('run') run = request.json.get('run')
try: try:
dest_location = Location('i4x', org, number, 'course', run) dest_location = Location(u'i4x', org, number, u'course', run)
except InvalidLocationError as error: except InvalidLocationError as error:
return JsonResponse({ return JsonResponse({
"ErrMsg": _("Unable to create course '{name}'.\n\n{err}").format( "ErrMsg": _("Unable to create course '{name}'.\n\n{err}").format(
...@@ -286,8 +286,10 @@ def create_new_course(request): ...@@ -286,8 +286,10 @@ def create_new_course(request):
course_search_location = bson.son.SON({ course_search_location = bson.son.SON({
'_id.tag': 'i4x', '_id.tag': 'i4x',
# cannot pass regex to Location constructor; thus this hack # cannot pass regex to Location constructor; thus this hack
'_id.org': re.compile('^{}$'.format(dest_location.org), re.IGNORECASE), # pylint: disable=E1101
'_id.course': re.compile('^{}$'.format(dest_location.course), re.IGNORECASE), '_id.org': re.compile(u'^{}$'.format(dest_location.org), re.IGNORECASE | re.UNICODE),
# pylint: disable=E1101
'_id.course': re.compile(u'^{}$'.format(dest_location.course), re.IGNORECASE | re.UNICODE),
'_id.category': 'course', '_id.category': 'course',
}) })
courses = modulestore().collection.find(course_search_location, fields=('_id')) courses = modulestore().collection.find(course_search_location, fields=('_id'))
......
...@@ -113,7 +113,8 @@ def xblock_handler(request, tag=None, package_id=None, branch=None, version_guid ...@@ -113,7 +113,8 @@ def xblock_handler(request, tag=None, package_id=None, branch=None, version_guid
return render_to_response('component.html', { return render_to_response('component.html', {
'preview': get_preview_html(request, component), 'preview': get_preview_html(request, component),
'editor': content 'editor': content,
'label': component.display_name or component.category,
}) })
elif request.method == 'DELETE': elif request.method == 'DELETE':
delete_children = str_to_bool(request.REQUEST.get('recurse', 'False')) delete_children = str_to_bool(request.REQUEST.get('recurse', 'False'))
......
...@@ -26,6 +26,8 @@ from .session_kv_store import SessionKeyValueStore ...@@ -26,6 +26,8 @@ from .session_kv_store import SessionKeyValueStore
from .helpers import render_from_lms from .helpers import render_from_lms
from ..utils import get_course_for_item from ..utils import get_course_for_item
from contentstore.views.access import get_user_role
__all__ = ['preview_handler'] __all__ = ['preview_handler']
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -39,7 +41,7 @@ def handler_prefix(block, handler='', suffix=''): ...@@ -39,7 +41,7 @@ def handler_prefix(block, handler='', suffix=''):
Trailing `/`s are removed from the returned url. Trailing `/`s are removed from the returned url.
""" """
return reverse('preview_handler', kwargs={ return reverse('preview_handler', kwargs={
'usage_id': quote_slashes(str(block.scope_ids.usage_id)), 'usage_id': quote_slashes(unicode(block.scope_ids.usage_id).encode('utf-8')),
'handler': handler, 'handler': handler,
'suffix': suffix, 'suffix': suffix,
}).rstrip('/?') }).rstrip('/?')
...@@ -132,6 +134,7 @@ def _preview_module_system(request, descriptor): ...@@ -132,6 +134,7 @@ def _preview_module_system(request, descriptor):
), ),
), ),
error_descriptor_class=ErrorDescriptor, error_descriptor_class=ErrorDescriptor,
get_user_role=lambda: get_user_role(request.user, descriptor.location, course_id),
) )
......
...@@ -153,6 +153,11 @@ COURSES_WITH_UNSAFE_CODE = ENV_TOKENS.get("COURSES_WITH_UNSAFE_CODE", []) ...@@ -153,6 +153,11 @@ COURSES_WITH_UNSAFE_CODE = ENV_TOKENS.get("COURSES_WITH_UNSAFE_CODE", [])
#Timezone overrides #Timezone overrides
TIME_ZONE = ENV_TOKENS.get('TIME_ZONE', TIME_ZONE) TIME_ZONE = ENV_TOKENS.get('TIME_ZONE', TIME_ZONE)
# Translation overrides
LANGUAGES = ENV_TOKENS.get('LANGUAGES', LANGUAGES)
LANGUAGE_CODE = ENV_TOKENS.get('LANGUAGE_CODE', LANGUAGE_CODE)
USE_I18N = ENV_TOKENS.get('USE_I18N', USE_I18N)
ENV_FEATURES = ENV_TOKENS.get('FEATURES', ENV_TOKENS.get('MITX_FEATURES', {})) ENV_FEATURES = ENV_TOKENS.get('FEATURES', ENV_TOKENS.get('MITX_FEATURES', {}))
for feature, value in ENV_FEATURES.items(): for feature, value in ENV_FEATURES.items():
FEATURES[feature] = value FEATURES[feature] = value
...@@ -224,6 +229,11 @@ TRACKING_BACKENDS.update(AUTH_TOKENS.get("TRACKING_BACKENDS", {})) ...@@ -224,6 +229,11 @@ TRACKING_BACKENDS.update(AUTH_TOKENS.get("TRACKING_BACKENDS", {}))
SUBDOMAIN_BRANDING = ENV_TOKENS.get('SUBDOMAIN_BRANDING', {}) SUBDOMAIN_BRANDING = ENV_TOKENS.get('SUBDOMAIN_BRANDING', {})
VIRTUAL_UNIVERSITIES = ENV_TOKENS.get('VIRTUAL_UNIVERSITIES', []) VIRTUAL_UNIVERSITIES = ENV_TOKENS.get('VIRTUAL_UNIVERSITIES', [])
##### ACCOUNT LOCKOUT DEFAULT PARAMETERS #####
MAX_FAILED_LOGIN_ATTEMPTS_ALLOWED = ENV_TOKENS.get("MAX_FAILED_LOGIN_ATTEMPTS_ALLOWED", 5)
MAX_FAILED_LOGIN_ATTEMPTS_LOCKOUT_PERIOD_SECS = ENV_TOKENS.get("MAX_FAILED_LOGIN_ATTEMPTS_LOCKOUT_PERIOD_SECS", 15 * 60)
MICROSITE_CONFIGURATION = ENV_TOKENS.get('MICROSITE_CONFIGURATION', {}) MICROSITE_CONFIGURATION = ENV_TOKENS.get('MICROSITE_CONFIGURATION', {})
MICROSITE_ROOT_DIR = ENV_TOKENS.get('MICROSITE_ROOT_DIR') MICROSITE_ROOT_DIR = ENV_TOKENS.get('MICROSITE_ROOT_DIR')
if len(MICROSITE_CONFIGURATION.keys()) > 0: if len(MICROSITE_CONFIGURATION.keys()) > 0:
...@@ -233,3 +243,13 @@ if len(MICROSITE_CONFIGURATION.keys()) > 0: ...@@ -233,3 +243,13 @@ if len(MICROSITE_CONFIGURATION.keys()) > 0:
VIRTUAL_UNIVERSITIES, VIRTUAL_UNIVERSITIES,
microsites_root=path(MICROSITE_ROOT_DIR) microsites_root=path(MICROSITE_ROOT_DIR)
) )
#### PASSWORD POLICY SETTINGS #####
PASSWORD_MIN_LENGTH = ENV_TOKENS.get("PASSWORD_MIN_LENGTH")
PASSWORD_MAX_LENGTH = ENV_TOKENS.get("PASSWORD_MAX_LENGTH")
PASSWORD_COMPLEXITY = ENV_TOKENS.get("PASSWORD_COMPLEXITY", {})
PASSWORD_DICTIONARY_EDIT_DISTANCE_THRESHOLD = ENV_TOKENS.get("PASSWORD_DICTIONARY_EDIT_DISTANCE_THRESHOLD")
PASSWORD_DICTIONARY = ENV_TOKENS.get("PASSWORD_DICTIONARY", [])
### INACTIVITY SETTINGS ####
SESSION_INACTIVITY_TIMEOUT_IN_SECONDS = AUTH_TOKENS.get("SESSION_INACTIVITY_TIMEOUT_IN_SECONDS")
# -*- coding: utf-8 -*-
""" """
This is the common settings file, intended to set sane defaults. If you have a This is the common settings file, intended to set sane defaults. If you have a
piece of configuration that's dependent on a set of feature flags being set, piece of configuration that's dependent on a set of feature flags being set,
...@@ -63,9 +64,15 @@ FEATURES = { ...@@ -63,9 +64,15 @@ FEATURES = {
# edX has explicitly added them to the course creator group. # edX has explicitly added them to the course creator group.
'ENABLE_CREATOR_GROUP': False, 'ENABLE_CREATOR_GROUP': False,
# whether to use password policy enforcement or not
'ENFORCE_PASSWORD_POLICY': False,
# If set to True, Studio won't restrict the set of advanced components # If set to True, Studio won't restrict the set of advanced components
# to just those pre-approved by edX # to just those pre-approved by edX
'ALLOW_ALL_ADVANCED_COMPONENTS': False, 'ALLOW_ALL_ADVANCED_COMPONENTS': False,
# Turn off account locking if failed login attempts exceeds a limit
'ENABLE_MAX_FAILED_LOGIN_ATTEMPTS': False,
} }
ENABLE_JASMINE = False ENABLE_JASMINE = False
...@@ -113,9 +120,11 @@ TEMPLATE_CONTEXT_PROCESSORS = ( ...@@ -113,9 +120,11 @@ TEMPLATE_CONTEXT_PROCESSORS = (
'django.core.context_processors.request', 'django.core.context_processors.request',
'django.core.context_processors.static', 'django.core.context_processors.static',
'django.contrib.messages.context_processors.messages', 'django.contrib.messages.context_processors.messages',
'django.core.context_processors.i18n',
'django.contrib.auth.context_processors.auth', # this is required for admin 'django.contrib.auth.context_processors.auth', # this is required for admin
'django.core.context_processors.csrf', 'django.core.context_processors.csrf',
'dealer.contrib.django.staff.context_processor', # access git revision 'dealer.contrib.django.staff.context_processor', # access git revision
'contentstore.context_processors.doc_url',
) )
# use the ratelimit backend to prevent brute force attacks # use the ratelimit backend to prevent brute force attacks
...@@ -165,12 +174,16 @@ MIDDLEWARE_CLASSES = ( ...@@ -165,12 +174,16 @@ MIDDLEWARE_CLASSES = (
'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
'track.middleware.TrackMiddleware', 'track.middleware.TrackMiddleware',
'edxmako.middleware.MakoMiddleware',
# Allows us to dark-launch particular languages
'dark_lang.middleware.DarkLangMiddleware',
# Detects user-requested locale from 'accept-language' header in http request # Detects user-requested locale from 'accept-language' header in http request
'django.middleware.locale.LocaleMiddleware', 'django.middleware.locale.LocaleMiddleware',
'django.middleware.transaction.TransactionMiddleware', 'django.middleware.transaction.TransactionMiddleware',
# needs to run after locale middleware (or anything that modifies the request context)
'edxmako.middleware.MakoMiddleware',
# catches any uncaught RateLimitExceptions and returns a 403 instead of a 500 # catches any uncaught RateLimitExceptions and returns a 403 instead of a 500
'ratelimitbackend.middleware.RateLimitMiddleware', 'ratelimitbackend.middleware.RateLimitMiddleware',
...@@ -242,12 +255,8 @@ STATICFILES_DIRS = [ ...@@ -242,12 +255,8 @@ STATICFILES_DIRS = [
TIME_ZONE = 'America/New_York' # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name TIME_ZONE = 'America/New_York' # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
LANGUAGE_CODE = 'en' # http://www.i18nguy.com/unicode/language-identifiers.html LANGUAGE_CODE = 'en' # http://www.i18nguy.com/unicode/language-identifiers.html
# We want i18n to be turned off in production, at least until we have full localizations. LANGUAGES = lms.envs.common.LANGUAGES
# Thus we want the Django translation engine to be disabled. Otherwise even without USE_I18N = True
# localization files, if the user's browser is set to a language other than us-en,
# strings like "login" and "password" will be translated and the rest of the page will be
# in English, which is confusing.
USE_I18N = False
USE_L10N = True USE_L10N = True
# Localization strings (e.g. django.po) are under this directory # Localization strings (e.g. django.po) are under this directory
...@@ -404,6 +413,9 @@ INSTALLED_APPS = ( ...@@ -404,6 +413,9 @@ INSTALLED_APPS = (
'south', 'south',
'method_override', 'method_override',
# Database-backed configuration
'config_models',
# Monitor the status of services # Monitor the status of services
'service_status', 'service_status',
...@@ -436,7 +448,12 @@ INSTALLED_APPS = ( ...@@ -436,7 +448,12 @@ INSTALLED_APPS = (
'django.contrib.admin', 'django.contrib.admin',
# for managing course modes # for managing course modes
'course_modes' 'course_modes',
# Dark-launching languages
'dark_lang',
# Student identity reverification
'reverification',
) )
...@@ -463,6 +480,14 @@ TRACKING_BACKENDS = { ...@@ -463,6 +480,14 @@ TRACKING_BACKENDS = {
} }
} }
#### PASSWORD POLICY SETTINGS #####
PASSWORD_MIN_LENGTH = None
PASSWORD_MAX_LENGTH = None
PASSWORD_COMPLEXITY = {}
PASSWORD_DICTIONARY_EDIT_DISTANCE_THRESHOLD = None
PASSWORD_DICTIONARY = []
# We're already logging events, and we don't want to capture user # We're already logging events, and we don't want to capture user
# names/passwords. Heartbeat events are likely not interesting. # names/passwords. Heartbeat events are likely not interesting.
TRACKING_IGNORE_URL_PATTERNS = [r'^/event', r'^/login', r'^/heartbeat'] TRACKING_IGNORE_URL_PATTERNS = [r'^/event', r'^/login', r'^/heartbeat']
...@@ -474,3 +499,8 @@ YOUTUBE_API = { ...@@ -474,3 +499,8 @@ YOUTUBE_API = {
'url': "http://video.google.com/timedtext", 'url': "http://video.google.com/timedtext",
'params': {'lang': 'en', 'v': 'set_youtube_id_of_11_symbols_here'} 'params': {'lang': 'en', 'v': 'set_youtube_id_of_11_symbols_here'}
} }
##### ACCOUNT LOCKOUT DEFAULT PARAMETERS #####
MAX_FAILED_LOGIN_ATTEMPTS_ALLOWED = 5
MAX_FAILED_LOGIN_ATTEMPTS_LOCKOUT_PERIOD_SECS = 15 * 60
...@@ -9,11 +9,6 @@ from .common import * ...@@ -9,11 +9,6 @@ from .common import *
from logsettings import get_logger_config from logsettings import get_logger_config
DEBUG = True DEBUG = True
USE_I18N = True
# For displaying the dummy text, we need to provide a language mapping.
LANGUAGES = (
('eo', 'Esperanto'),
)
TEMPLATE_DEBUG = DEBUG TEMPLATE_DEBUG = DEBUG
LOGGING = get_logger_config(ENV_ROOT / "log", LOGGING = get_logger_config(ENV_ROOT / "log",
logging_env="dev", logging_env="dev",
......
...@@ -84,8 +84,8 @@ require(["domReady", "jquery", "underscore", "js/utils/cancel_on_escape"], ...@@ -84,8 +84,8 @@ require(["domReady", "jquery", "underscore", "js/utils/cancel_on_escape"],
if (required) { if (required) {
return required; return required;
} }
if (item !== encodeURIComponent(item)) { if (/\s/g.test(item)) {
return gettext('Please do not use any spaces or special characters in this field.'); return gettext('Please do not use any spaces in this field.');
} }
return ''; return '';
}; };
......
...@@ -881,19 +881,23 @@ body.unit { ...@@ -881,19 +881,23 @@ body.unit {
padding: $baseline/4 $baseline/2; padding: $baseline/4 $baseline/2;
top: 0; top: 0;
left: 0; left: 0;
border-bottom: 1px solid $gray-l4;
background: $gray-l5;
} }
.component-header { .component-header {
display: inline-block; display: inline-block;
width: 50%; overflow: hidden;
vertical-align: middle; padding: $baseline/2 0px 0px $baseline/4;
max-width: 60%;
color: $gray-l1;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 300;
} }
.component-actions { .component-actions {
display: inline-block; display: inline-block;
float: right; float: right;
max-width: 40%;
vertical-align: middle; vertical-align: middle;
text-align: center; text-align: center;
} }
...@@ -934,6 +938,7 @@ body.unit { ...@@ -934,6 +938,7 @@ body.unit {
.action-button-text { .action-button-text {
padding-left: 1px; padding-left: 1px;
vertical-align: bottom; vertical-align: bottom;
text-transform: uppercase;
line-height: 17px; line-height: 17px;
} }
......
...@@ -3,10 +3,10 @@ ...@@ -3,10 +3,10 @@
<%namespace name='static' file='static_content.html'/> <%namespace name='static' file='static_content.html'/>
<!doctype html> <!doctype html>
<!--[if IE 7]><html class="ie7 lte9 lte8 lte7"><![endif]--> <!--[if IE 7]><html class="ie7 lte9 lte8 lte7" lang="${LANGUAGE_CODE}"><![endif]-->
<!--[if IE 8]><html class="ie8 lte9 lte8"><![endif]--> <!--[if IE 8]><html class="ie8 lte9 lte8" lang="${LANGUAGE_CODE}"><![endif]-->
<!--[if IE 9]><html class="ie9 lte9"><![endif]--> <!--[if IE 9]><html class="ie9 lte9" lang="${LANGUAGE_CODE}"><![endif]-->
<!--[if gt IE 9]><!--><html><!--<![endif]--> <!--[if gt IE 9]><!--><html lang="${LANGUAGE_CODE}"><!--<![endif]-->
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"> <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
...@@ -32,7 +32,7 @@ ...@@ -32,7 +32,7 @@
<%block name="header_extras"></%block> <%block name="header_extras"></%block>
</head> </head>
<body class="<%block name='bodyclass'></%block> hide-wip"> <body class="<%block name='bodyclass'></%block> hide-wip lang_${LANGUAGE_CODE}">
<a class="nav-skip" href="#content">${_("Skip to this view's content")}</a> <a class="nav-skip" href="#content">${_("Skip to this view's content")}</a>
<script type="text/javascript"> <script type="text/javascript">
......
...@@ -2,53 +2,54 @@ ...@@ -2,53 +2,54 @@
<%namespace name='static' file='static_content.html'/> <%namespace name='static' file='static_content.html'/>
<div class="wrapper wrapper-component-editor"> <div class="wrapper wrapper-component-editor">
<div class="component-editor"> <div class="component-editor">
<div class="component-edit-header"> <div class="component-edit-header">
<span class="component-name"></span> <span class="component-name"></span>
<ul class="nav-edit-modes"> <ul class="nav-edit-modes">
<li id="editor-mode" class="mode active-mode" aria-controls="editor-tab" role="tab"> <li id="editor-mode" class="mode active-mode" aria-controls="editor-tab" role="tab">
<a href="#">${_("Editor")}</a> <a href="#">${_("Editor")}</a>
</li> </li>
<li id="settings-mode" class="mode active-mode" aria-controls="settings-tab" role="tab"> <li id="settings-mode" class="mode active-mode" aria-controls="settings-tab" role="tab">
<a href="#">${_("Settings")}</a> <a href="#">${_("Settings")}</a>
</li> </li>
</ul> </ul>
</div> <!-- Editor Header --> </div> <!-- Editor Header -->
<div class="component-edit-modes"> <div class="component-edit-modes">
<div class="module-editor"> <div class="module-editor">
${editor} ${editor}
</div> </div>
</div> </div>
<div class="row module-actions"> <div class="row module-actions">
<a href="#" class="save-button action-primary action">${_("Save")}</a> <a href="#" class="save-button action-primary action">${_("Save")}</a>
<a href="#" class="cancel-button action-secondary action">${_("Cancel")}</a> <a href="#" class="cancel-button action-secondary action">${_("Cancel")}</a>
</div> <!-- Module Actions--> </div> <!-- Module Actions-->
</div> </div>
</div> </div>
<div class="wrapper wrapper-component-action-header"> <div class="wrapper wrapper-component-action-header">
<div class="component-header"> <div class="component-header">
</div> ${label}
<ul class="component-actions"> </div>
<li class="action-item action-edit"> <ul class="component-actions">
<a href="#" class="edit-button action-button"> <li class="action-item action-edit">
<i class="icon-edit"></i> <a href="#" class="edit-button action-button">
<span class="action-button-text">${_("Edit")}</span> <i class="icon-edit"></i>
</a> <span class="action-button-text">${_("Edit")}</span>
</li> </a>
<li class="action-item action-duplicate"> </li>
<a href="#" data-tooltip="Duplicate" class="duplicate-button action-button"> <li class="action-item action-duplicate">
<i class="icon-copy"></i> <a href="#" data-tooltip="${_("Duplicate")}" class="duplicate-button action-button">
<span class="sr">${_("Duplicate this component")}</span> <i class="icon-copy"></i>
</a> <span class="sr">${_("Duplicate this component")}</span>
</li> </a>
<li class="action-item action-delete"> </li>
<a href="#" data-tooltip="Delete" class="delete-button action-button"> <li class="action-item action-delete">
<i class="icon-trash"></i> <a href="#" data-tooltip="${_("Delete")}" class="delete-button action-button">
<span class="sr">${_("Delete this component")}</span> <i class="icon-trash"></i>
</a> <span class="sr">${_("Delete this component")}</span>
</li> </a>
</ul> </li>
</ul>
</div> </div>
<span data-tooltip="${_("Drag to reorder")}" class="drag-handle"></span> <span data-tooltip="${_("Drag to reorder")}" class="drag-handle"></span>
${preview} ${preview}
......
...@@ -47,6 +47,7 @@ require(["js/models/explicit_url", "coffee/src/views/tabs"], function(TabsModel, ...@@ -47,6 +47,7 @@ require(["js/models/explicit_url", "coffee/src/views/tabs"], function(TabsModel,
<section class="content"> <section class="content">
<div class="introduction has-links"> <div class="introduction has-links">
<p class="copy">${_("Use Static Pages to share a syllabus, a calendar, handouts, or other supplements to your courseware.")}</p> <p class="copy">${_("Use Static Pages to share a syllabus, a calendar, handouts, or other supplements to your courseware.")}</p>
<p class="copy">${_("NOTE: all content on Static Pages will be visible to anyone who knows the URL, regardless of whether they are registered in the course or not.")}</p>
<nav class="nav-introduction-supplementary"> <nav class="nav-introduction-supplementary">
<ul> <ul>
<li class="nav-item"> <li class="nav-item">
......
...@@ -17,7 +17,7 @@ ...@@ -17,7 +17,7 @@
<p class="introduction">${_("Ready to start creating online courses? Sign up below and start creating your first edX course today.")}</p> <p class="introduction">${_("Ready to start creating online courses? Sign up below and start creating your first edX course today.")}</p>
<article class="content-primary" role="main"> <article class="content-primary" role="main">
<form id="register_form" method="post" action="register_post"> <form id="register_form" method="post">
<div id="register_error" name="register_error" class="message message-status message-status error"> <div id="register_error" name="register_error" class="message message-status message-status error">
</div> </div>
...@@ -107,31 +107,25 @@ require(["jquery", "jquery.cookie"], function($) { ...@@ -107,31 +107,25 @@ require(["jquery", "jquery.cookie"], function($) {
$("label").removeClass("is-focused"); $("label").removeClass("is-focused");
}); });
// form validation
function postJSON(url, data, callback) {
$.ajax({type:'POST',
url: url,
dataType: 'json',
data: data,
success: callback,
headers : {'X-CSRFToken': $.cookie('csrftoken')}
});
}
$('form#register_form').submit(function(e) { $('form#register_form').submit(function(e) {
e.preventDefault(); e.preventDefault();
var submit_data = $('#register_form').serialize(); var submit_data = $('#register_form').serialize();
postJSON('/create_account', $.ajax({
submit_data, url: '/create_account',
function(json) { type: 'POST',
if(json.success) { dataType: 'json',
location.href = "${'/course'}"; data: submit_data,
} else { headers: {'X-CSRFToken': $.cookie('csrftoken')},
$('#register_error').html(json.value).stop().addClass('is-shown'); success: function(json) {
} location.href = "/course";
} },
); error: function(jqXHR, textStatus, errorThrown) {
json = $.parseJSON(jqXHR.responseText);
$('#register_error').html(json.value).stop().addClass('is-shown');
},
notifyOnError: false
});
}); });
}); });
</script> </script>
......
...@@ -164,7 +164,7 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit" ...@@ -164,7 +164,7 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit"
% endif % endif
${_("with the subsection {link_start}{name}{link_end}").format( ${_("with the subsection {link_start}{name}{link_end}").format(
name=subsection.display_name_with_default, name=subsection.display_name_with_default,
link_start='<a href="{url}">'.format(url=subsection_url), link_start=u'<a href="{url}">'.format(url=subsection_url),
link_end='</a>', link_end='</a>',
)} )}
</p> </p>
......
...@@ -42,7 +42,7 @@ urlpatterns = patterns('', # nopep8 ...@@ -42,7 +42,7 @@ urlpatterns = patterns('', # nopep8
urlpatterns += patterns( urlpatterns += patterns(
'', '',
url(r'^create_account$', 'student.views.create_account'), url(r'^create_account$', 'student.views.create_account', name='create_account'),
url(r'^activate/(?P<key>[^/]*)$', 'student.views.activate_account', name='activate'), url(r'^activate/(?P<key>[^/]*)$', 'student.views.activate_account', name='activate'),
# ajax view that actually does the work # ajax view that actually does the work
......
...@@ -110,12 +110,12 @@ def instance_key(model, instance_or_pk): ...@@ -110,12 +110,12 @@ def instance_key(model, instance_or_pk):
def set_cached_content(content): def set_cached_content(content):
cache.set(str(content.location), content) cache.set(unicode(content.location).encode("utf-8"), content)
def get_cached_content(location): def get_cached_content(location):
return cache.get(str(location)) return cache.get(unicode(location).encode("utf-8"))
def del_cached_content(location): def del_cached_content(location):
cache.delete(str(location)) cache.delete(unicode(location).encode("utf-8"))
"""
Model-Based Configuration
=========================
This app allows other apps to easily define a configuration model
that can be hooked into the admin site to allow configuration management
with auditing.
Installation
------------
Add ``config_models`` to your ``INSTALLED_APPS`` list.
Usage
-----
Create a subclass of ``ConfigurationModel``, with fields for each
value that needs to be configured::
class MyConfiguration(ConfigurationModel):
frobble_timeout = IntField(default=10)
frazzle_target = TextField(defalut="debug")
This is a normal django model, so it must be synced and migrated as usual.
The default values for the fields in the ``ConfigurationModel`` will be
used if no configuration has yet been created.
Register that class with the Admin site, using the ``ConfigurationAdminModel``::
from django.contrib import admin
from config_models.admin import ConfigurationModelAdmin
admin.site.register(MyConfiguration, ConfigurationModelAdmin)
Use the configuration in your code::
def my_view(self, request):
config = MyConfiguration.current()
fire_the_missiles(config.frazzle_target, timeout=config.frobble_timeout)
Use the admin site to add new configuration entries. The most recently created
entry is considered to be ``current``.
Configuration
-------------
The current ``ConfigurationModel`` will be cached in the ``configuration`` django cache,
or in the ``default`` cache if ``configuration`` doesn't exist. You can specify the cache
timeout in each ``ConfigurationModel`` by setting the ``cache_timeout`` property.
You can change the name of the cache key used by the ``ConfigurationModel`` by overriding
the ``cache_key_name`` function.
Extension
---------
``ConfigurationModels`` are just django models, so they can be extended with new fields
and migrated as usual. Newly added fields must have default values and should be nullable,
so that rollbacks to old versions of configuration work correctly.
"""
"""
Admin site models for managing :class:`.ConfigurationModel` subclasses
"""
from django.forms import models
from django.contrib import admin
from django.http import HttpResponseRedirect
from django.core.urlresolvers import reverse
# pylint: disable=protected-access
class ConfigurationModelAdmin(admin.ModelAdmin):
"""
:class:`~django.contrib.admin.ModelAdmin` for :class:`.ConfigurationModel` subclasses
"""
date_hierarchy = 'change_date'
def get_actions(self, request):
return {
'revert': (ConfigurationModelAdmin.revert, 'revert', 'Revert to the selected configuration')
}
def get_list_display(self, request):
return self.model._meta.get_all_field_names()
# Don't allow deletion of configuration
def has_delete_permission(self, request, obj=None):
return False
# Make all fields read-only when editing an object
def get_readonly_fields(self, request, obj=None):
if obj: # editing an existing object
return self.model._meta.get_all_field_names()
return self.readonly_fields
def add_view(self, request, form_url='', extra_context=None):
# Prepopulate new configuration entries with the value of the current config
get = request.GET.copy()
get.update(models.model_to_dict(self.model.current()))
request.GET = get
return super(ConfigurationModelAdmin, self).add_view(request, form_url, extra_context)
# Hide the save buttons in the change view
def change_view(self, request, object_id, form_url='', extra_context=None):
extra_context = extra_context or {}
extra_context['readonly'] = True
return super(ConfigurationModelAdmin, self).change_view(
request,
object_id,
form_url,
extra_context=extra_context
)
def save_model(self, request, obj, form, change):
obj.changed_by = request.user
super(ConfigurationModelAdmin, self).save_model(request, obj, form, change)
def revert(self, request, queryset):
"""
Admin action to revert a configuration back to the selected value
"""
if queryset.count() != 1:
self.message_user(request, "Please select a single configuration to revert to.")
return
target = queryset[0]
target.id = None
self.save_model(request, target, None, False)
self.message_user(request, "Reverted configuration.")
return HttpResponseRedirect(
reverse(
'admin:{}_{}_change'.format(
self.model._meta.app_label,
self.model._meta.module_name,
),
args=(target.id,),
)
)
"""
Django Model baseclass for database-backed configuration.
"""
from django.db import models
from django.contrib.auth.models import User
from django.core.cache import get_cache, InvalidCacheBackendError
try:
cache = get_cache('configuration') # pylint: disable=invalid-name
except InvalidCacheBackendError:
from django.core.cache import cache
class ConfigurationModel(models.Model):
"""
Abstract base class for model-based configuration
Properties:
cache_timeout (int): The number of seconds that this configuration
should be cached
"""
class Meta(object): # pylint: disable=missing-docstring
abstract = True
# The number of seconds
cache_timeout = 600
change_date = models.DateTimeField(auto_now_add=True)
changed_by = models.ForeignKey(User, editable=False, null=True, on_delete=models.PROTECT)
enabled = models.BooleanField(default=False)
def save(self, *args, **kwargs):
"""
Clear the cached value when saving a new configuration entry
"""
super(ConfigurationModel, self).save(*args, **kwargs)
cache.delete(self.cache_key_name())
@classmethod
def cache_key_name(cls):
"""Return the name of the key to use to cache the current configuration"""
return 'configuration/{}/current'.format(cls.__name__)
@classmethod
def current(cls):
"""
Return the active configuration entry, either from cache,
from the database, or by creating a new empty entry (which is not
persisted).
"""
cached = cache.get(cls.cache_key_name())
if cached is not None:
return cached
try:
current = cls.objects.order_by('-change_date')[0]
except IndexError:
current = cls()
cache.set(cls.cache_key_name(), current, cls.cache_timeout)
return current
"""
Override the submit_row template tag to remove all save buttons from the
admin dashboard change view if the context has readonly marked in it.
"""
from django.contrib.admin.templatetags.admin_modify import register
from django.contrib.admin.templatetags.admin_modify import submit_row as original_submit_row
@register.inclusion_tag('admin/submit_line.html', takes_context=True)
def submit_row(context):
"""
Overrides 'django.contrib.admin.templatetags.admin_modify.submit_row'.
Manipulates the context going into that function by hiding all of the buttons
in the submit row if the key `readonly` is set in the context.
"""
ctx = original_submit_row(context)
if context.get('readonly', False):
ctx.update({
'show_delete_link': False,
'show_save_as_new': False,
'show_save_and_add_another': False,
'show_save_and_continue': False,
'show_save': False,
})
else:
return ctx
"""
Tests of ConfigurationModel
"""
from django.contrib.auth.models import User
from django.db import models
from django.test import TestCase
from freezegun import freeze_time
from mock import patch
from config_models.models import ConfigurationModel
class ExampleConfig(ConfigurationModel):
"""
Test model for testing ``ConfigurationModels``.
"""
cache_timeout = 300
string_field = models.TextField()
int_field = models.IntegerField(default=10)
@patch('config_models.models.cache')
class ConfigurationModelTests(TestCase):
"""
Tests of ConfigurationModel
"""
def setUp(self):
self.user = User()
self.user.save()
def test_cache_deleted_on_save(self, mock_cache):
ExampleConfig(changed_by=self.user).save()
mock_cache.delete.assert_called_with(ExampleConfig.cache_key_name())
def test_cache_key_name(self, _mock_cache):
self.assertEquals(ExampleConfig.cache_key_name(), 'configuration/ExampleConfig/current')
def test_no_config_empty_cache(self, mock_cache):
mock_cache.get.return_value = None
current = ExampleConfig.current()
self.assertEquals(current.int_field, 10)
self.assertEquals(current.string_field, '')
mock_cache.set.assert_called_with(ExampleConfig.cache_key_name(), current, 300)
def test_no_config_full_cache(self, mock_cache):
current = ExampleConfig.current()
self.assertEquals(current, mock_cache.get.return_value)
def test_config_ordering(self, mock_cache):
mock_cache.get.return_value = None
with freeze_time('2012-01-01'):
first = ExampleConfig(changed_by=self.user)
first.string_field = 'first'
first.save()
second = ExampleConfig(changed_by=self.user)
second.string_field = 'second'
second.save()
self.assertEquals(ExampleConfig.current().string_field, 'second')
def test_cache_set(self, mock_cache):
mock_cache.get.return_value = None
first = ExampleConfig(changed_by=self.user)
first.string_field = 'first'
first.save()
ExampleConfig.current()
mock_cache.set.assert_called_with(ExampleConfig.cache_key_name(), first, 300)
...@@ -77,7 +77,7 @@ def is_commentable_cohorted(course_id, commentable_id): ...@@ -77,7 +77,7 @@ def is_commentable_cohorted(course_id, commentable_id):
# inline discussions are cohorted by default # inline discussions are cohorted by default
ans = True ans = True
log.debug("is_commentable_cohorted({0}, {1}) = {2}".format(course_id, log.debug(u"is_commentable_cohorted({0}, {1}) = {2}".format(course_id,
commentable_id, commentable_id,
ans)) ans))
return ans return ans
......
"""
Language Translation Dark Launching
===================================
This app adds the ability to launch language translations that
are only accessible through the use of a specific query parameter
(and are not activated by browser settings).
Installation
------------
Add the ``DarkLangMiddleware`` to your list of ``MIDDLEWARE_CLASSES``.
It must come after the ``SessionMiddleware``, and before the ``LocaleMiddleware``.
Run migrations to install the configuration table.
Use the admin site to add a new ``DarkLangConfig`` that is enabled, and lists the
languages that should be released.
"""
"""
Admin site bindings for dark_lang
"""
from django.contrib import admin
from config_models.admin import ConfigurationModelAdmin
from dark_lang.models import DarkLangConfig
admin.site.register(DarkLangConfig, ConfigurationModelAdmin)
"""
Middleware for dark-launching languages. These languages won't be used
when determining which translation to give a user based on their browser
header, but can be selected by setting the ``preview-lang`` query parameter
to the language code.
Adding the query parameter ``clear-lang`` will reset the language stored
in the user's session.
This middleware must be placed before the LocaleMiddleware, but after
the SessionMiddleware.
"""
from django.utils.translation.trans_real import parse_accept_lang_header
from dark_lang.models import DarkLangConfig
class DarkLangMiddleware(object):
"""
Middleware for dark-launching languages.
This is configured by creating ``DarkLangConfig`` rows in the database,
using the django admin site.
"""
@property
def released_langs(self):
"""
Current list of released languages
"""
return DarkLangConfig.current().released_languages_list
def process_request(self, request):
"""
Prevent user from requesting un-released languages except by using the preview-lang query string.
"""
if not DarkLangConfig.current().enabled:
return
self._clean_accept_headers(request)
self._activate_preview_language(request)
def _is_released(self, lang_code):
"""
``True`` iff one of the values in ``self.released_langs`` is a prefix of ``lang_code``.
"""
return any(lang_code.lower().startswith(released_lang.lower()) for released_lang in self.released_langs)
def _format_accept_value(self, lang, priority=1.0):
"""
Formats lang and priority into a valid accept header fragment.
"""
return "{};q={}".format(lang, priority)
def _clean_accept_headers(self, request):
"""
Remove any language that is not either in ``self.released_langs`` or
a territory of one of those languages.
"""
accept = request.META.get('HTTP_ACCEPT_LANGUAGE', None)
if accept is None or accept == '*':
return
new_accept = ", ".join(
self._format_accept_value(lang, priority)
for lang, priority
in parse_accept_lang_header(accept)
if self._is_released(lang)
)
request.META['HTTP_ACCEPT_LANGUAGE'] = new_accept
def _activate_preview_language(self, request):
"""
If the request has the get parameter ``preview-lang``,
and that language appears doesn't appear in ``self.released_langs``,
then set the session ``django_language`` to that language.
"""
if 'clear-lang' in request.GET:
if 'django_language' in request.session:
del request.session['django_language']
preview_lang = request.GET.get('preview-lang', None)
if not preview_lang:
return
if preview_lang in self.released_langs:
return
request.session['django_language'] = preview_lang
# -*- coding: utf-8 -*-
import datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# Adding model 'DarkLangConfig'
db.create_table('dark_lang_darklangconfig', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('change_date', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)),
('changed_by', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'], null=True, on_delete=models.PROTECT)),
('enabled', self.gf('django.db.models.fields.BooleanField')(default=False)),
('released_languages', self.gf('django.db.models.fields.TextField')(blank=True)),
))
db.send_create_signal('dark_lang', ['DarkLangConfig'])
def backwards(self, orm):
# Deleting model 'DarkLangConfig'
db.delete_table('dark_lang_darklangconfig')
models = {
'auth.group': {
'Meta': {'object_name': 'Group'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
},
'auth.permission': {
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
},
'auth.user': {
'Meta': {'object_name': 'User'},
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
},
'contenttypes.contenttype': {
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
},
'dark_lang.darklangconfig': {
'Meta': {'object_name': 'DarkLangConfig'},
'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}),
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'released_languages': ('django.db.models.fields.TextField', [], {'blank': 'True'})
}
}
complete_apps = ['dark_lang']
\ No newline at end of file
# -*- coding: utf-8 -*-
import datetime
from south.db import db
from south.v2 import DataMigration
from django.db import models
class Migration(DataMigration):
def forwards(self, orm):
"""
Enable DarkLang by default when it is installed, to prevent accidental
release of testing languages.
"""
orm.DarkLangConfig(enabled=True).save()
def backwards(self, orm):
"Write your backwards methods here."
raise RuntimeError("Cannot reverse this migration.")
models = {
'auth.group': {
'Meta': {'object_name': 'Group'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
},
'auth.permission': {
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
},
'auth.user': {
'Meta': {'object_name': 'User'},
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
},
'contenttypes.contenttype': {
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
},
'dark_lang.darklangconfig': {
'Meta': {'object_name': 'DarkLangConfig'},
'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}),
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'released_languages': ('django.db.models.fields.TextField', [], {'blank': 'True'})
}
}
complete_apps = ['dark_lang']
symmetrical = True
"""
Models for the dark-launching languages
"""
from django.db import models
from config_models.models import ConfigurationModel
class DarkLangConfig(ConfigurationModel):
"""
Configuration for the dark_lang django app
"""
released_languages = models.TextField(
blank=True,
help_text="A comma-separated list of language codes to release to the public."
)
@property
def released_languages_list(self):
"""
``released_languages`` as a list of language codes.
"""
if not self.released_languages.strip(): # pylint: disable=no-member
return []
return [lang.strip() for lang in self.released_languages.split(',')] # pylint: disable=no-member
"""
Tests of DarkLangMiddleware
"""
from django.contrib.auth.models import User
from django.http import HttpRequest
from django.test import TestCase
from mock import Mock
from dark_lang.middleware import DarkLangMiddleware
from dark_lang.models import DarkLangConfig
UNSET = object()
def set_if_set(dct, key, value):
"""
Sets ``key`` in ``dct`` to ``value``
unless ``value`` is ``UNSET``
"""
if value is not UNSET:
dct[key] = value
class DarkLangMiddlewareTests(TestCase):
"""
Tests of DarkLangMiddleware
"""
def setUp(self):
self.user = User()
self.user.save()
DarkLangConfig(
released_languages='rel',
changed_by=self.user,
enabled=True
).save()
def process_request(self, django_language=UNSET, accept=UNSET, preview_lang=UNSET, clear_lang=UNSET):
"""
Build a request and then process it using the ``DarkLangMiddleware``.
Args:
django_language (str): The language code to set in request.session['django_language']
accept (str): The accept header to set in request.META['HTTP_ACCEPT_LANGUAGE']
preview_lang (str): The value to set in request.GET['preview_lang']
clear_lang (str): The value to set in request.GET['clear_lang']
"""
session = {}
set_if_set(session, 'django_language', django_language)
meta = {}
set_if_set(meta, 'HTTP_ACCEPT_LANGUAGE', accept)
get = {}
set_if_set(get, 'preview-lang', preview_lang)
set_if_set(get, 'clear-lang', clear_lang)
request = Mock(
spec=HttpRequest,
session=session,
META=meta,
GET=get
)
self.assertIsNone(DarkLangMiddleware().process_request(request))
return request
def assertAcceptEquals(self, value, request):
"""
Assert that the HTML_ACCEPT_LANGUAGE header in request
is equal to value
"""
self.assertEquals(
value,
request.META.get('HTTP_ACCEPT_LANGUAGE', UNSET)
)
def test_empty_accept(self):
self.assertAcceptEquals(UNSET, self.process_request())
def test_wildcard_accept(self):
self.assertAcceptEquals('*', self.process_request(accept='*'))
def test_released_accept(self):
self.assertAcceptEquals(
'rel;q=1.0',
self.process_request(accept='rel;q=1.0')
)
def test_unreleased_accept(self):
self.assertAcceptEquals(
'rel;q=1.0',
self.process_request(accept='rel;q=1.0, unrel;q=0.5')
)
def test_accept_multiple_released_langs(self):
DarkLangConfig(
released_languages=('rel, unrel'),
changed_by=self.user,
enabled=True
).save()
self.assertAcceptEquals(
'rel;q=1.0, unrel;q=0.5',
self.process_request(accept='rel;q=1.0, unrel;q=0.5')
)
self.assertAcceptEquals(
'rel;q=1.0, unrel;q=0.5',
self.process_request(accept='rel;q=1.0, notrel;q=0.3, unrel;q=0.5')
)
self.assertAcceptEquals(
'rel;q=1.0, unrel;q=0.5',
self.process_request(accept='notrel;q=0.3, rel;q=1.0, unrel;q=0.5')
)
def test_accept_released_territory(self):
self.assertAcceptEquals(
'rel-ter;q=1.0, rel;q=0.5',
self.process_request(accept='rel-ter;q=1.0, rel;q=0.5')
)
def test_accept_mixed_case(self):
self.assertAcceptEquals(
'rel-TER;q=1.0, REL;q=0.5',
self.process_request(accept='rel-TER;q=1.0, REL;q=0.5')
)
DarkLangConfig(
released_languages=('REL-TER'),
changed_by=self.user,
enabled=True
).save()
self.assertAcceptEquals(
'rel-ter;q=1.0',
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
"""
self.assertEquals(
value,
request.session.get('django_language', UNSET)
)
def test_preview_lang_with_released_language(self):
self.assertSessionLangEquals(
UNSET,
self.process_request(preview_lang='rel')
)
self.assertSessionLangEquals(
'notrel',
self.process_request(preview_lang='rel', django_language='notrel')
)
def test_preview_lang_with_dark_language(self):
self.assertSessionLangEquals(
'unrel',
self.process_request(preview_lang='unrel')
)
self.assertSessionLangEquals(
'unrel',
self.process_request(preview_lang='unrel', django_language='notrel')
)
def test_clear_lang(self):
self.assertSessionLangEquals(
UNSET,
self.process_request(clear_lang=True)
)
self.assertSessionLangEquals(
UNSET,
self.process_request(clear_lang=True, django_language='rel')
)
self.assertSessionLangEquals(
UNSET,
self.process_request(clear_lang=True, django_language='unrel')
)
def test_disabled(self):
DarkLangConfig(enabled=False, changed_by=self.user).save()
self.assertAcceptEquals(
'notrel;q=0.3, rel;q=1.0, unrel;q=0.5',
self.process_request(accept='notrel;q=0.3, rel;q=1.0, unrel;q=0.5')
)
self.assertSessionLangEquals(
'rel',
self.process_request(clear_lang=True, django_language='rel')
)
self.assertSessionLangEquals(
'unrel',
self.process_request(clear_lang=True, django_language='unrel')
)
self.assertSessionLangEquals(
'rel',
self.process_request(preview_lang='unrel', django_language='rel')
)
...@@ -11,5 +11,6 @@ ...@@ -11,5 +11,6 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
LOOKUP = {}
lookup = None from .paths import add_lookup, lookup_template
...@@ -12,8 +12,6 @@ ...@@ -12,8 +12,6 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
import ConfigParser
from django.conf import settings
from django.template import RequestContext from django.template import RequestContext
from util.request import safe_get_host from util.request import safe_get_host
requestcontext = None requestcontext = None
...@@ -26,21 +24,3 @@ class MakoMiddleware(object): ...@@ -26,21 +24,3 @@ class MakoMiddleware(object):
requestcontext = RequestContext(request) requestcontext = RequestContext(request)
requestcontext['is_secure'] = request.is_secure() requestcontext['is_secure'] = request.is_secure()
requestcontext['site'] = safe_get_host(request) requestcontext['site'] = safe_get_host(request)
requestcontext['doc_url'] = self.get_doc_url_func(request)
def get_doc_url_func(self, request):
config_file = open(settings.REPO_ROOT / "docs" / "config.ini")
config = ConfigParser.ConfigParser()
config.readfp(config_file)
# in the future, we will detect the locale; for now, we will
# hardcode en_us, since we only have English documentation
locale = "en_us"
def doc_url(token):
try:
return config.get(locale, token)
except ConfigParser.NoOptionError:
return config.get(locale, "default")
return doc_url
"""
Set up lookup paths for mako templates.
"""
import os
import pkg_resources
from django.conf import settings
from mako.lookup import TemplateLookup
from . import LOOKUP
class DynamicTemplateLookup(TemplateLookup):
"""
A specialization of the standard mako `TemplateLookup` class which allows
for adding directories progressively.
"""
def add_directory(self, directory):
"""
Add a new directory to the template lookup path.
"""
self.directories.append(os.path.normpath(directory))
def add_lookup(namespace, directory, package=None):
"""
Adds a new mako template lookup directory to the given namespace.
If `package` is specified, `pkg_resources` is used to look up the directory
inside the given package. Otherwise `directory` is assumed to be a path
in the filesystem.
"""
templates = LOOKUP.get(namespace)
if not templates:
LOOKUP[namespace] = templates = DynamicTemplateLookup(
module_directory=settings.MAKO_MODULE_DIR,
output_encoding='utf-8',
input_encoding='utf-8',
default_filters=['decode.utf8'],
encoding_errors='replace',
)
if package:
directory = pkg_resources.resource_filename(package, directory)
templates.add_directory(directory)
def lookup_template(namespace, name):
"""
Look up a Mako template by namespace and name.
"""
return LOOKUP[namespace].get_template(name)
...@@ -18,7 +18,7 @@ import logging ...@@ -18,7 +18,7 @@ import logging
from microsite_configuration.middleware import MicrositeConfiguration from microsite_configuration.middleware import MicrositeConfiguration
import edxmako from edxmako import lookup_template
import edxmako.middleware import edxmako.middleware
from django.conf import settings from django.conf import settings
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
...@@ -100,7 +100,7 @@ def render_to_string(template_name, dictionary, context=None, namespace='main'): ...@@ -100,7 +100,7 @@ def render_to_string(template_name, dictionary, context=None, namespace='main'):
if context: if context:
context_dictionary.update(context) context_dictionary.update(context)
# fetch and render template # fetch and render template
template = edxmako.lookup[namespace].get_template(template_name) template = lookup_template(namespace, template_name)
return template.render_unicode(**context_dictionary) return template.render_unicode(**context_dictionary)
......
""" """
Initialize the mako template lookup Initialize the mako template lookup
""" """
import tempdir
from django.conf import settings from django.conf import settings
from mako.lookup import TemplateLookup from . import add_lookup
import edxmako
def run(): def run():
"""Setup mako variables and lookup object""" """
# Set all mako variables based on django settings Setup mako lookup directories.
"""
template_locations = settings.MAKO_TEMPLATES template_locations = settings.MAKO_TEMPLATES
module_directory = getattr(settings, 'MAKO_MODULE_DIR', None) for namespace, directories in template_locations.items():
for directory in directories:
if module_directory is None: add_lookup(namespace, directory)
module_directory = tempdir.mkdtemp_clean()
lookup = {}
for location in template_locations:
lookup[location] = TemplateLookup(
directories=template_locations[location],
module_directory=module_directory,
output_encoding='utf-8',
input_encoding='utf-8',
default_filters=['decode.utf8'],
encoding_errors='replace',
)
edxmako.lookup = lookup
...@@ -19,7 +19,7 @@ from edxmako.shortcuts import marketing_link ...@@ -19,7 +19,7 @@ from edxmako.shortcuts import marketing_link
import edxmako import edxmako
import edxmako.middleware import edxmako.middleware
django_variables = ['lookup', 'output_encoding', 'encoding_errors'] DJANGO_VARIABLES = ['output_encoding', 'encoding_errors']
# TODO: We should make this a Django Template subclass that simply has the MakoTemplate inside of it? (Intead of inheriting from MakoTemplate) # TODO: We should make this a Django Template subclass that simply has the MakoTemplate inside of it? (Intead of inheriting from MakoTemplate)
...@@ -34,8 +34,8 @@ class Template(MakoTemplate): ...@@ -34,8 +34,8 @@ class Template(MakoTemplate):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
"""Overrides base __init__ to provide django variable overrides""" """Overrides base __init__ to provide django variable overrides"""
if not kwargs.get('no_django', False): if not kwargs.get('no_django', False):
overrides = dict([(k, getattr(edxmako, k, None),) for k in django_variables]) overrides = {k: getattr(edxmako, k, None) for k in DJANGO_VARIABLES}
overrides['lookup'] = overrides['lookup']['main'] overrides['lookup'] = edxmako.LOOKUP['main']
kwargs.update(overrides) kwargs.update(overrides)
super(Template, self).__init__(*args, **kwargs) super(Template, self).__init__(*args, **kwargs)
......
from django.test import TestCase from django.test import TestCase
from django.test.utils import override_settings from django.test.utils import override_settings
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from edxmako import add_lookup, LOOKUP
from edxmako.shortcuts import marketing_link from edxmako.shortcuts import marketing_link
from mock import patch from mock import patch
from util.testing import UrlResetMixin from util.testing import UrlResetMixin
...@@ -24,3 +25,15 @@ class ShortcutsTests(UrlResetMixin, TestCase): ...@@ -24,3 +25,15 @@ class ShortcutsTests(UrlResetMixin, TestCase):
expected_link = reverse('login') expected_link = reverse('login')
link = marketing_link('ABOUT') link = marketing_link('ABOUT')
self.assertEquals(link, expected_link) self.assertEquals(link, expected_link)
class AddLookupTests(TestCase):
"""
Test the `add_lookup` function.
"""
@patch('edxmako.LOOKUP', {})
def test_with_package(self):
add_lookup('test', 'management', __name__)
dirs = LOOKUP['test'].directories
self.assertEqual(len(dirs), 1)
self.assertTrue(dirs[0].endswith('management'))
...@@ -202,7 +202,7 @@ class ShibSPTest(ModuleStoreTestCase): ...@@ -202,7 +202,7 @@ class ShibSPTest(ModuleStoreTestCase):
else: else:
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, self.assertContains(response,
("<title>Preferences for {platform_name}</title>" ("Preferences for {platform_name}"
.format(platform_name=settings.PLATFORM_NAME))) .format(platform_name=settings.PLATFORM_NAME)))
# no audit logging calls # no audit logging calls
self.assertEquals(len(audit_log_calls), 0) self.assertEquals(len(audit_log_calls), 0)
......
from .templatetags.microsite import page_title_breadcrumbs
"""
Template tags and helper functions for displaying breadcrumbs in page titles
based on the current micro site.
"""
from django import template
from django.conf import settings
from microsite_configuration.middleware import MicrositeConfiguration
register = template.Library()
def page_title_breadcrumbs(*crumbs, **kwargs):
"""
This function creates a suitable page title in the form:
Specific | Less Specific | General | edX
It will output the correct platform name for the request.
Pass in a `separator` kwarg to override the default of " | "
"""
separator = kwargs.get("separator", " | ")
if crumbs:
return u'{}{}{}'.format(separator.join(crumbs), separator, platform_name())
else:
return platform_name()
@register.simple_tag(name="page_title_breadcrumbs", takes_context=True)
def page_title_breadcrumbs_tag(context, *crumbs):
"""
Django template that creates breadcrumbs for page titles:
{% page_title_breadcrumbs "Specific" "Less Specific" General %}
"""
return page_title_breadcrumbs(*crumbs)
@register.simple_tag(name="platform_name")
def platform_name():
"""
Django template tag that outputs the current platform name:
{% platform_name %}
"""
return MicrositeConfiguration.get_microsite_configuration_value('platform_name', settings.PLATFORM_NAME)
# -*- coding: utf-8 -*-
"""
Tests microsite_configuration templatetags and helper functions.
"""
from django.test import TestCase
from django.conf import settings
from .templatetags import microsite
class MicroSiteTests(TestCase):
def test_breadcrumbs(self):
crumbs = ['my', 'less specific', 'Page']
expected = u'my | less specific | Page | edX'
title = microsite.page_title_breadcrumbs(*crumbs)
self.assertEqual(expected, title)
def test_unicode_title(self):
crumbs = [u'øo', u'π tastes gréât', u'驴']
expected = u'øo | π tastes gréât | 驴 | edX'
title = microsite.page_title_breadcrumbs(*crumbs)
self.assertEqual(expected, title)
def test_platform_name(self):
pname = microsite.platform_name()
self.assertEqual(pname, settings.PLATFORM_NAME)
def test_breadcrumb_tag(self):
crumbs = ['my', 'less specific', 'Page']
expected = u'my | less specific | Page | edX'
title = microsite.page_title_breadcrumbs_tag(None, *crumbs)
self.assertEqual(expected, title)
\ No newline at end of file
"""
Reverification admin
"""
from ratelimitbackend import admin
from reverification.models import MidcourseReverificationWindow
admin.site.register(MidcourseReverificationWindow)
# -*- coding: utf-8 -*-
import datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# Adding model 'MidcourseReverificationWindow'
db.create_table('reverification_midcoursereverificationwindow', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('course_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)),
('start_date', self.gf('django.db.models.fields.DateTimeField')(default=None, null=True, blank=True)),
('end_date', self.gf('django.db.models.fields.DateTimeField')(default=None, null=True, blank=True)),
))
db.send_create_signal('reverification', ['MidcourseReverificationWindow'])
def backwards(self, orm):
# Deleting model 'MidcourseReverificationWindow'
db.delete_table('reverification_midcoursereverificationwindow')
models = {
'reverification.midcoursereverificationwindow': {
'Meta': {'object_name': 'MidcourseReverificationWindow'},
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
'end_date': ('django.db.models.fields.DateTimeField', [], {'default': 'None', 'null': 'True', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'start_date': ('django.db.models.fields.DateTimeField', [], {'default': 'None', 'null': 'True', 'blank': 'True'})
}
}
complete_apps = ['reverification']
\ No newline at end of file
"""
Models for reverification features common to both lms and studio
"""
from datetime import datetime
import pytz
from django.core.exceptions import ValidationError
from django.db import models
from util.validate_on_save import ValidateOnSaveMixin
class MidcourseReverificationWindow(ValidateOnSaveMixin, models.Model):
"""
Defines the start and end times for midcourse reverification for a particular course.
There can be many MidcourseReverificationWindows per course, but they cannot have
overlapping time ranges. This is enforced by this class's clean() method.
"""
# the course that this window is attached to
course_id = models.CharField(max_length=255, db_index=True)
start_date = models.DateTimeField(default=None, null=True, blank=True)
end_date = models.DateTimeField(default=None, null=True, blank=True)
def clean(self):
"""
Gives custom validation for the MidcourseReverificationWindow model.
Prevents overlapping windows for any particular course.
"""
query = MidcourseReverificationWindow.objects.filter(
course_id=self.course_id,
end_date__gte=self.start_date,
start_date__lte=self.end_date
)
if query.count() > 0:
raise ValidationError('Reverification windows cannot overlap for a given course.')
@classmethod
def window_open_for_course(cls, course_id):
"""
Returns a boolean, True if the course is currently asking for reverification, else False.
"""
now = datetime.now(pytz.UTC)
return cls.get_window(course_id, now) is not None
@classmethod
def get_window(cls, course_id, date):
"""
Returns the window that is open for a particular course for a particular date.
If no such window is open, or if more than one window is open, returns None.
"""
try:
return cls.objects.get(course_id=course_id, start_date__lte=date, end_date__gte=date)
except cls.DoesNotExist:
return None
"""
verify_student factories
"""
from reverification.models import MidcourseReverificationWindow
from factory.django import DjangoModelFactory
import pytz
from datetime import timedelta, datetime
# Factories don't have __init__ methods, and are self documenting
# pylint: disable=W0232
class MidcourseReverificationWindowFactory(DjangoModelFactory):
""" Creates a generic MidcourseReverificationWindow. """
FACTORY_FOR = MidcourseReverificationWindow
course_id = u'MITx/999/Robot_Super_Course'
# By default this factory creates a window that is currently open
start_date = datetime.now(pytz.UTC) - timedelta(days=100)
end_date = datetime.now(pytz.UTC) + timedelta(days=100)
"""
Tests for Reverification models
"""
from datetime import timedelta, datetime
import pytz
from django.core.exceptions import ValidationError
from django.test import TestCase
from django.test.utils import override_settings
from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE
from reverification.models import MidcourseReverificationWindow
from reverification.tests.factories import MidcourseReverificationWindowFactory
from xmodule.modulestore.tests.factories import CourseFactory
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
class TestMidcourseReverificationWindow(TestCase):
""" Tests for MidcourseReverificationWindow objects """
def setUp(self):
course = CourseFactory.create()
self.course_id = course.id
def test_window_open_for_course(self):
# Should return False if no windows exist for a course
self.assertFalse(MidcourseReverificationWindow.window_open_for_course(self.course_id))
# Should return False if a window exists, but it's not in the current timeframe
MidcourseReverificationWindowFactory(
course_id=self.course_id,
start_date=datetime.now(pytz.utc) - timedelta(days=10),
end_date=datetime.now(pytz.utc) - timedelta(days=5)
)
self.assertFalse(MidcourseReverificationWindow.window_open_for_course(self.course_id))
# Should return True if a non-expired window exists
MidcourseReverificationWindowFactory(
course_id=self.course_id,
start_date=datetime.now(pytz.utc) - timedelta(days=3),
end_date=datetime.now(pytz.utc) + timedelta(days=3)
)
self.assertTrue(MidcourseReverificationWindow.window_open_for_course(self.course_id))
def test_get_window(self):
# if no window exists, returns None
self.assertIsNone(MidcourseReverificationWindow.get_window(self.course_id, datetime.now(pytz.utc)))
# we should get the expected window otherwise
window_valid = MidcourseReverificationWindowFactory(
course_id=self.course_id,
start_date=datetime.now(pytz.utc) - timedelta(days=3),
end_date=datetime.now(pytz.utc) + timedelta(days=3)
)
self.assertEquals(
window_valid,
MidcourseReverificationWindow.get_window(self.course_id, datetime.now(pytz.utc))
)
def test_no_overlapping_windows(self):
window_valid = MidcourseReverificationWindow(
course_id=self.course_id,
start_date=datetime.now(pytz.utc) - timedelta(days=3),
end_date=datetime.now(pytz.utc) + timedelta(days=3)
)
window_valid.save()
with self.assertRaises(ValidationError):
window_invalid = MidcourseReverificationWindow(
course_id=self.course_id,
start_date=datetime.now(pytz.utc) - timedelta(days=2),
end_date=datetime.now(pytz.utc) + timedelta(days=4)
)
window_invalid.save()
...@@ -19,7 +19,7 @@ def _url_replace_regex(prefix): ...@@ -19,7 +19,7 @@ def _url_replace_regex(prefix):
To anyone contemplating making this more complicated: To anyone contemplating making this more complicated:
http://xkcd.com/1171/ http://xkcd.com/1171/
""" """
return r""" return ur"""
(?x) # flags=re.VERBOSE (?x) # flags=re.VERBOSE
(?P<quote>\\?['"]) # the opening quotes (?P<quote>\\?['"]) # the opening quotes
(?P<prefix>{prefix}) # the prefix (?P<prefix>{prefix}) # the prefix
...@@ -152,7 +152,7 @@ def replace_static_urls(text, data_directory, course_id=None, static_asset_path= ...@@ -152,7 +152,7 @@ def replace_static_urls(text, data_directory, course_id=None, static_asset_path=
return "".join([quote, url, quote]) return "".join([quote, url, quote])
return re.sub( return re.sub(
_url_replace_regex('(?:{static_url}|/static/)(?!{data_dir})'.format( _url_replace_regex(u'(?:{static_url}|/static/)(?!{data_dir})'.format(
static_url=settings.STATIC_URL, static_url=settings.STATIC_URL,
data_dir=static_asset_path or data_directory data_dir=static_asset_path or data_directory
)), )),
......
"""
Utility functions for validating forms
"""
from django import forms from django import forms
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.auth.forms import PasswordResetForm from django.contrib.auth.forms import PasswordResetForm
......
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.contrib.auth.models import User from django.contrib.auth.models import User
import edxmako from edxmako import lookup_template
class Command(BaseCommand): class Command(BaseCommand):
...@@ -15,8 +15,8 @@ body, and an _subject.txt for the subject. ''' ...@@ -15,8 +15,8 @@ body, and an _subject.txt for the subject. '''
#text = open(args[0]).read() #text = open(args[0]).read()
#subject = open(args[1]).read() #subject = open(args[1]).read()
users = User.objects.all() users = User.objects.all()
text = edxmako.lookup['main'].get_template('email/' + args[0] + ".txt").render() text = lookup_template('main', 'email/' + args[0] + ".txt").render()
subject = edxmako.lookup['main'].get_template('email/' + args[0] + "_subject.txt").render().strip() subject = lookup_template('main', 'email/' + args[0] + "_subject.txt").render().strip()
for user in users: for user in users:
if user.is_active: if user.is_active:
user.email_user(subject, text) user.email_user(subject, text)
...@@ -4,7 +4,7 @@ import time ...@@ -4,7 +4,7 @@ import time
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.conf import settings from django.conf import settings
import edxmako from edxmako import lookup_template
from django.core.mail import send_mass_mail from django.core.mail import send_mass_mail
import sys import sys
...@@ -39,8 +39,8 @@ rate -- messages per second ...@@ -39,8 +39,8 @@ rate -- messages per second
users = [u.strip() for u in open(user_file).readlines()] users = [u.strip() for u in open(user_file).readlines()]
message = edxmako.lookup['main'].get_template('emails/' + message_base + "_body.txt").render() message = lookup_template('main', 'emails/' + message_base + "_body.txt").render()
subject = edxmako.lookup['main'].get_template('emails/' + message_base + "_subject.txt").render().strip() subject = lookup_template('main', 'emails/' + message_base + "_subject.txt").render().strip()
rate = int(ratestr) rate = int(ratestr)
self.log_file = open(logfilename, "a+", buffering=0) self.log_file = open(logfilename, "a+", buffering=0)
......
...@@ -110,60 +110,6 @@ class Migration(SchemaMigration): ...@@ -110,60 +110,6 @@ class Migration(SchemaMigration):
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'unique': 'True'}) 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'unique': 'True'})
}, },
'student.testcenterregistration': {
'Meta': {'object_name': 'TestCenterRegistration'},
'accommodation_code': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}),
'accommodation_request': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}),
'authorization_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}),
'client_authorization_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '20', 'db_index': 'True'}),
'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}),
'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
'eligibility_appointment_date_first': ('django.db.models.fields.DateField', [], {'db_index': 'True'}),
'eligibility_appointment_date_last': ('django.db.models.fields.DateField', [], {'db_index': 'True'}),
'exam_series_code': ('django.db.models.fields.CharField', [], {'max_length': '15', 'db_index': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'processed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
'testcenter_user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['student.TestCenterUser']"}),
'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
'upload_error_message': ('django.db.models.fields.CharField', [], {'max_length': '512', 'blank': 'True'}),
'upload_status': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}),
'uploaded_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
'user_updated_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'})
},
'student.testcenteruser': {
'Meta': {'object_name': 'TestCenterUser'},
'address_1': ('django.db.models.fields.CharField', [], {'max_length': '40'}),
'address_2': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}),
'address_3': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}),
'candidate_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}),
'city': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}),
'client_candidate_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '50', 'db_index': 'True'}),
'company_name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '50', 'blank': 'True'}),
'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
'country': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}),
'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
'extension': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '8', 'blank': 'True'}),
'fax': ('django.db.models.fields.CharField', [], {'max_length': '35', 'blank': 'True'}),
'fax_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'blank': 'True'}),
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'db_index': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}),
'middle_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'phone': ('django.db.models.fields.CharField', [], {'max_length': '35'}),
'phone_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}),
'postal_code': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '16', 'blank': 'True'}),
'processed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
'salutation': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}),
'state': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}),
'suffix': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
'upload_error_message': ('django.db.models.fields.CharField', [], {'max_length': '512', 'blank': 'True'}),
'upload_status': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}),
'uploaded_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['auth.User']", 'unique': 'True'}),
'user_updated_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'})
},
'student.userprofile': { 'student.userprofile': {
'Meta': {'object_name': 'UserProfile', 'db_table': "'auth_userprofile'"}, 'Meta': {'object_name': 'UserProfile', 'db_table': "'auth_userprofile'"},
'allow_certificate': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 'allow_certificate': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
...@@ -197,4 +143,4 @@ class Migration(SchemaMigration): ...@@ -197,4 +143,4 @@ class Migration(SchemaMigration):
} }
} }
complete_apps = ['student'] complete_apps = ['student']
\ No newline at end of file
...@@ -95,60 +95,6 @@ class Migration(DataMigration): ...@@ -95,60 +95,6 @@ class Migration(DataMigration):
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'unique': 'True'}) 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'unique': 'True'})
}, },
'student.testcenterregistration': {
'Meta': {'object_name': 'TestCenterRegistration'},
'accommodation_code': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}),
'accommodation_request': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}),
'authorization_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}),
'client_authorization_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '20', 'db_index': 'True'}),
'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}),
'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
'eligibility_appointment_date_first': ('django.db.models.fields.DateField', [], {'db_index': 'True'}),
'eligibility_appointment_date_last': ('django.db.models.fields.DateField', [], {'db_index': 'True'}),
'exam_series_code': ('django.db.models.fields.CharField', [], {'max_length': '15', 'db_index': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'processed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
'testcenter_user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['student.TestCenterUser']"}),
'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
'upload_error_message': ('django.db.models.fields.CharField', [], {'max_length': '512', 'blank': 'True'}),
'upload_status': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}),
'uploaded_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
'user_updated_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'})
},
'student.testcenteruser': {
'Meta': {'object_name': 'TestCenterUser'},
'address_1': ('django.db.models.fields.CharField', [], {'max_length': '40'}),
'address_2': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}),
'address_3': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}),
'candidate_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}),
'city': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}),
'client_candidate_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '50', 'db_index': 'True'}),
'company_name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '50', 'blank': 'True'}),
'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
'country': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}),
'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
'extension': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '8', 'blank': 'True'}),
'fax': ('django.db.models.fields.CharField', [], {'max_length': '35', 'blank': 'True'}),
'fax_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'blank': 'True'}),
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'db_index': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}),
'middle_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'phone': ('django.db.models.fields.CharField', [], {'max_length': '35'}),
'phone_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}),
'postal_code': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '16', 'blank': 'True'}),
'processed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
'salutation': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}),
'state': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}),
'suffix': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
'upload_error_message': ('django.db.models.fields.CharField', [], {'max_length': '512', 'blank': 'True'}),
'upload_status': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}),
'uploaded_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}),
'user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['auth.User']", 'unique': 'True'}),
'user_updated_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'})
},
'student.userprofile': { 'student.userprofile': {
'Meta': {'object_name': 'UserProfile', 'db_table': "'auth_userprofile'"}, 'Meta': {'object_name': 'UserProfile', 'db_table': "'auth_userprofile'"},
'allow_certificate': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 'allow_certificate': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
......
...@@ -11,7 +11,7 @@ file and check it in at the same time as your model changes. To do that, ...@@ -11,7 +11,7 @@ file and check it in at the same time as your model changes. To do that,
3. Add the migration file created in edx-platform/common/djangoapps/student/migrations/ 3. Add the migration file created in edx-platform/common/djangoapps/student/migrations/
""" """
import crum import crum
from datetime import datetime from datetime import datetime, timedelta
import hashlib import hashlib
import json import json
import logging import logging
...@@ -29,6 +29,7 @@ from django.dispatch import receiver, Signal ...@@ -29,6 +29,7 @@ from django.dispatch import receiver, Signal
import django.dispatch import django.dispatch
from django.forms import ModelForm, forms from django.forms import ModelForm, forms
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django_countries import CountryField
from track import contexts from track import contexts
from track.views import server_track from track.views import server_track
from eventtracking import tracker from eventtracking import tracker
...@@ -213,6 +214,8 @@ class UserProfile(models.Model): ...@@ -213,6 +214,8 @@ class UserProfile(models.Model):
choices=LEVEL_OF_EDUCATION_CHOICES choices=LEVEL_OF_EDUCATION_CHOICES
) )
mailing_address = models.TextField(blank=True, null=True) mailing_address = models.TextField(blank=True, null=True)
city = models.TextField(blank=True, null=True)
country = CountryField(blank=True, null=True)
goals = models.TextField(blank=True, null=True) goals = models.TextField(blank=True, null=True)
allow_certificate = models.BooleanField(default=1) allow_certificate = models.BooleanField(default=1)
...@@ -286,6 +289,68 @@ EVENT_NAME_ENROLLMENT_ACTIVATED = 'edx.course.enrollment.activated' ...@@ -286,6 +289,68 @@ EVENT_NAME_ENROLLMENT_ACTIVATED = 'edx.course.enrollment.activated'
EVENT_NAME_ENROLLMENT_DEACTIVATED = 'edx.course.enrollment.deactivated' EVENT_NAME_ENROLLMENT_DEACTIVATED = 'edx.course.enrollment.deactivated'
class LoginFailures(models.Model):
"""
This model will keep track of failed login attempts
"""
user = models.ForeignKey(User)
failure_count = models.IntegerField(default=0)
lockout_until = models.DateTimeField(null=True)
@classmethod
def is_feature_enabled(cls):
"""
Returns whether the feature flag around this functionality has been set
"""
return settings.FEATURES['ENABLE_MAX_FAILED_LOGIN_ATTEMPTS']
@classmethod
def is_user_locked_out(cls, user):
"""
Static method to return in a given user has his/her account locked out
"""
try:
record = LoginFailures.objects.get(user=user)
if not record.lockout_until:
return False
now = datetime.now(UTC)
until = record.lockout_until
is_locked_out = until and now < until
return is_locked_out
except ObjectDoesNotExist:
return False
@classmethod
def increment_lockout_counter(cls, user):
"""
Ticks the failed attempt counter
"""
record, _ = LoginFailures.objects.get_or_create(user=user)
record.failure_count = record.failure_count + 1
max_failures_allowed = settings.MAX_FAILED_LOGIN_ATTEMPTS_ALLOWED
# did we go over the limit in attempts
if record.failure_count >= max_failures_allowed:
# yes, then store when this account is locked out until
lockout_period_secs = settings.MAX_FAILED_LOGIN_ATTEMPTS_LOCKOUT_PERIOD_SECS
record.lockout_until = datetime.now(UTC) + timedelta(seconds=lockout_period_secs)
record.save()
@classmethod
def clear_lockout_counter(cls, user):
"""
Removes the lockout counters (normally called after a successful login)
"""
try:
entry = LoginFailures.objects.get(user=user)
entry.delete()
except ObjectDoesNotExist:
return
class CourseEnrollment(models.Model): class CourseEnrollment(models.Model):
""" """
Represents a Student's Enrollment record for a single Course. You should Represents a Student's Enrollment record for a single Course. You should
......
...@@ -157,33 +157,32 @@ class CourseRole(GroupBasedRole): ...@@ -157,33 +157,32 @@ class CourseRole(GroupBasedRole):
# direct copy from auth.authz.get_all_course_role_groupnames will refactor to one impl asap # direct copy from auth.authz.get_all_course_role_groupnames will refactor to one impl asap
groupnames = [] groupnames = []
# pylint: disable=no-member
if isinstance(self.location, Location): if isinstance(self.location, Location):
try: try:
groupnames.append('{0}_{1}'.format(role, self.location.course_id)) groupnames.append(u'{0}_{1}'.format(role, self.location.course_id))
course_context = self.location.course_id # course_id is valid for translation course_context = self.location.course_id # course_id is valid for translation
except InvalidLocationError: # will occur on old locations where location is not of category course except InvalidLocationError: # will occur on old locations where location is not of category course
if course_context is None: if course_context is None:
raise CourseContextRequired() raise CourseContextRequired()
else: else:
groupnames.append('{0}_{1}'.format(role, course_context)) groupnames.append(u'{0}_{1}'.format(role, course_context))
try: try:
locator = loc_mapper().translate_location_to_course_locator(course_context, self.location) locator = loc_mapper().translate_location_to_course_locator(course_context, self.location)
groupnames.append('{0}_{1}'.format(role, locator.package_id)) groupnames.append(u'{0}_{1}'.format(role, locator.package_id))
except (InvalidLocationError, ItemNotFoundError): except (InvalidLocationError, ItemNotFoundError):
# if it's never been mapped, the auth won't be via the Locator syntax # if it's never been mapped, the auth won't be via the Locator syntax
pass pass
# least preferred legacy role_course format # least preferred legacy role_course format
groupnames.append('{0}_{1}'.format(role, self.location.course)) groupnames.append(u'{0}_{1}'.format(role, self.location.course)) # pylint: disable=E1101, E1103
elif isinstance(self.location, CourseLocator): elif isinstance(self.location, CourseLocator):
groupnames.append('{0}_{1}'.format(role, self.location.package_id)) groupnames.append(u'{0}_{1}'.format(role, self.location.package_id))
# handle old Location syntax # handle old Location syntax
old_location = loc_mapper().translate_locator_to_location(self.location, get_course=True) old_location = loc_mapper().translate_locator_to_location(self.location, get_course=True)
if old_location: if old_location:
# the slashified version of the course_id (myu/mycourse/myrun) # the slashified version of the course_id (myu/mycourse/myrun)
groupnames.append('{0}_{1}'.format(role, old_location.course_id)) groupnames.append(u'{0}_{1}'.format(role, old_location.course_id))
# add the least desirable but sometimes occurring format. # add the least desirable but sometimes occurring format.
groupnames.append('{0}_{1}'.format(role, old_location.course)) groupnames.append(u'{0}_{1}'.format(role, old_location.course)) # pylint: disable=E1101, E1103
super(CourseRole, self).__init__(groupnames) super(CourseRole, self).__init__(groupnames)
...@@ -193,15 +192,14 @@ class OrgRole(GroupBasedRole): ...@@ -193,15 +192,14 @@ class OrgRole(GroupBasedRole):
A named role in a particular org A named role in a particular org
""" """
def __init__(self, role, location): def __init__(self, role, location):
# pylint: disable=no-member
location = Location(location) location = Location(location)
super(OrgRole, self).__init__(['{}_{}'.format(role, location.org)]) super(OrgRole, self).__init__([u'{}_{}'.format(role, location.org)])
class CourseStaffRole(CourseRole): class CourseStaffRole(CourseRole):
"""A Staff member of a course""" """A Staff member of a course"""
ROLE = 'staff' ROLE = 'staff'
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(CourseStaffRole, self).__init__(self.ROLE, *args, **kwargs) super(CourseStaffRole, self).__init__(self.ROLE, *args, **kwargs)
......
...@@ -255,7 +255,7 @@ class ExternalAuthShibTest(ModuleStoreTestCase): ...@@ -255,7 +255,7 @@ class ExternalAuthShibTest(ModuleStoreTestCase):
noshib_response = self.client.get(TARGET_URL, follow=True) noshib_response = self.client.get(TARGET_URL, follow=True)
self.assertEqual(noshib_response.redirect_chain[-1], self.assertEqual(noshib_response.redirect_chain[-1],
('http://testserver/accounts/login?next={url}'.format(url=TARGET_URL), 302)) ('http://testserver/accounts/login?next={url}'.format(url=TARGET_URL), 302))
self.assertContains(noshib_response, ("<title>Log into your {platform_name} Account</title>" self.assertContains(noshib_response, ("Log into your {platform_name} Account | {platform_name}"
.format(platform_name=settings.PLATFORM_NAME))) .format(platform_name=settings.PLATFORM_NAME)))
self.assertEqual(noshib_response.status_code, 200) self.assertEqual(noshib_response.status_code, 200)
......
"""
Test `massemail` and `massemailtxt` commands.
"""
import mock
import pkg_resources
from django.core import mail
from django.test import TestCase
from edxmako import add_lookup
from ..management.commands import massemail
from ..management.commands import massemailtxt
class TestMassEmailCommands(TestCase):
"""
Test `massemail` and `massemailtxt` commands.
"""
@mock.patch('edxmako.LOOKUP', {})
def test_massemailtxt(self):
"""
Test the `massemailtext` command.
"""
add_lookup('main', '', package=__name__)
userfile = pkg_resources.resource_filename(__name__, 'test_massemail_users.txt')
command = massemailtxt.Command()
command.handle(userfile, 'test', '/dev/null', 10)
self.assertEqual(len(mail.outbox), 2)
self.assertEqual(mail.outbox[0].to, ["Fred"])
self.assertEqual(mail.outbox[0].subject, "Test subject.")
self.assertEqual(mail.outbox[0].body.strip(), "Test body.")
self.assertEqual(mail.outbox[1].to, ["Barney"])
self.assertEqual(mail.outbox[1].subject, "Test subject.")
self.assertEqual(mail.outbox[1].body.strip(), "Test body.")
@mock.patch('edxmako.LOOKUP', {})
@mock.patch('student.management.commands.massemail.User')
def test_massemail(self, usercls):
"""
Test the `massemail` command.
"""
add_lookup('main', '', package=__name__)
fred = mock.Mock()
barney = mock.Mock()
usercls.objects.all.return_value = [fred, barney]
command = massemail.Command()
command.handle('test')
fred.email_user.assert_called_once_with('Test subject.', 'Test body.\n')
barney.email_user.assert_called_once_with('Test subject.', 'Test body.\n')
# -*- coding: utf-8 -*-
"""
This test file will verify proper password policy enforcement, which is an option feature
"""
import json
import uuid
from django.test import TestCase
from django.core.urlresolvers import reverse
from mock import patch
from django.test.utils import override_settings
@patch.dict("django.conf.settings.FEATURES", {'ENFORCE_PASSWORD_POLICY': True})
class TestPasswordPolicy(TestCase):
"""
Go through some password policy tests to make sure things are properly working
"""
def setUp(self):
super(TestPasswordPolicy, self).setUp()
self.url = reverse('create_account')
self.url_params = {
'username': 'foo_bar' + uuid.uuid4().hex,
'email': 'foo' + uuid.uuid4().hex + '@bar.com',
'name': 'username',
'terms_of_service': 'true',
'honor_code': 'true',
}
@override_settings(PASSWORD_MIN_LENGTH=6)
def test_password_length_too_short(self):
self.url_params['password'] = 'aaa'
response = self.client.post(self.url, self.url_params)
self.assertEqual(response.status_code, 400)
obj = json.loads(response.content)
self.assertEqual(
obj['value'],
"Password: Invalid Length (must be 6 characters or more)",
)
@override_settings(PASSWORD_MIN_LENGTH=6)
def test_password_length_long_enough(self):
self.url_params['password'] = 'ThisIsALongerPassword'
response = self.client.post(self.url, self.url_params)
self.assertEqual(response.status_code, 200)
obj = json.loads(response.content)
self.assertTrue(obj['success'])
@override_settings(PASSWORD_MAX_LENGTH=12)
def test_password_length_too_long(self):
self.url_params['password'] = 'ThisPasswordIsWayTooLong'
response = self.client.post(self.url, self.url_params)
self.assertEqual(response.status_code, 400)
obj = json.loads(response.content)
self.assertEqual(
obj['value'],
"Password: Invalid Length (must be 12 characters or less)",
)
@patch.dict("django.conf.settings.PASSWORD_COMPLEXITY", {'UPPER': 3})
def test_password_not_enough_uppercase(self):
self.url_params['password'] = 'thisshouldfail'
response = self.client.post(self.url, self.url_params)
self.assertEqual(response.status_code, 400)
obj = json.loads(response.content)
self.assertEqual(
obj['value'],
"Password: Must be more complex (must contain 3 or more uppercase characters)",
)
@patch.dict("django.conf.settings.PASSWORD_COMPLEXITY", {'UPPER': 3})
def test_password_enough_uppercase(self):
self.url_params['password'] = 'ThisShouldPass'
response = self.client.post(self.url, self.url_params)
self.assertEqual(response.status_code, 200)
obj = json.loads(response.content)
self.assertTrue(obj['success'])
@patch.dict("django.conf.settings.PASSWORD_COMPLEXITY", {'LOWER': 3})
def test_password_not_enough_lowercase(self):
self.url_params['password'] = 'THISSHOULDFAIL'
response = self.client.post(self.url, self.url_params)
self.assertEqual(response.status_code, 400)
obj = json.loads(response.content)
self.assertEqual(
obj['value'],
"Password: Must be more complex (must contain 3 or more lowercase characters)",
)
@patch.dict("django.conf.settings.PASSWORD_COMPLEXITY", {'LOWER': 3})
def test_password_not_enough_lowercase(self):
self.url_params['password'] = 'ThisShouldPass'
response = self.client.post(self.url, self.url_params)
self.assertEqual(response.status_code, 200)
obj = json.loads(response.content)
self.assertTrue(obj['success'])
@patch.dict("django.conf.settings.PASSWORD_COMPLEXITY", {'DIGITS': 3})
def test_not_enough_digits(self):
self.url_params['password'] = 'thishasnodigits'
response = self.client.post(self.url, self.url_params)
self.assertEqual(response.status_code, 400)
obj = json.loads(response.content)
self.assertEqual(
obj['value'],
"Password: Must be more complex (must contain 3 or more digits)",
)
@patch.dict("django.conf.settings.PASSWORD_COMPLEXITY", {'DIGITS': 3})
def test_enough_digits(self):
self.url_params['password'] = 'Th1sSh0uldPa88'
response = self.client.post(self.url, self.url_params)
self.assertEqual(response.status_code, 200)
obj = json.loads(response.content)
self.assertTrue(obj['success'])
@patch.dict("django.conf.settings.PASSWORD_COMPLEXITY", {'PUNCTUATION': 3})
def test_not_enough_punctuations(self):
self.url_params['password'] = 'thisshouldfail'
response = self.client.post(self.url, self.url_params)
self.assertEqual(response.status_code, 400)
obj = json.loads(response.content)
self.assertEqual(
obj['value'],
"Password: Must be more complex (must contain 3 or more punctuation characters)",
)
@patch.dict("django.conf.settings.PASSWORD_COMPLEXITY", {'PUNCTUATION': 3})
def test_enough_punctuations(self):
self.url_params['password'] = 'Th!sSh.uldPa$*'
response = self.client.post(self.url, self.url_params)
self.assertEqual(response.status_code, 200)
obj = json.loads(response.content)
self.assertTrue(obj['success'])
@patch.dict("django.conf.settings.PASSWORD_COMPLEXITY", {'WORDS': 3})
def test_not_enough_words(self):
self.url_params['password'] = 'thisshouldfail'
response = self.client.post(self.url, self.url_params)
self.assertEqual(response.status_code, 400)
obj = json.loads(response.content)
self.assertEqual(
obj['value'],
"Password: Must be more complex (must contain 3 or more unique words)",
)
@patch.dict("django.conf.settings.PASSWORD_COMPLEXITY", {'WORDS': 3})
def test_enough_wordss(self):
self.url_params['password'] = u'this should pass'
response = self.client.post(self.url, self.url_params)
self.assertEqual(response.status_code, 200)
obj = json.loads(response.content)
self.assertTrue(obj['success'])
@patch.dict("django.conf.settings.PASSWORD_COMPLEXITY", {
'PUNCTUATION': 3,
'WORDS': 3,
'DIGITS': 3,
'LOWER': 3,
'UPPER': 3,
})
def test_multiple_errors_fail(self):
self.url_params['password'] = 'thisshouldfail'
response = self.client.post(self.url, self.url_params)
self.assertEqual(response.status_code, 400)
obj = json.loads(response.content)
errstring = ("Password: Must be more complex ("
"must contain 3 or more uppercase characters, "
"must contain 3 or more digits, "
"must contain 3 or more punctuation characters, "
"must contain 3 or more unique words"
")")
self.assertEqual(obj['value'], errstring)
@patch.dict("django.conf.settings.PASSWORD_COMPLEXITY", {
'PUNCTUATION': 3,
'WORDS': 3,
'DIGITS': 3,
'LOWER': 3,
'UPPER': 3,
})
def test_multiple_errors_pass(self):
self.url_params['password'] = u'tH1s Sh0u!d P3#$'
response = self.client.post(self.url, self.url_params)
self.assertEqual(response.status_code, 200)
obj = json.loads(response.content)
self.assertTrue(obj['success'])
@override_settings(PASSWORD_DICTIONARY=['foo', 'bar'])
@override_settings(PASSWORD_DICTIONARY_EDIT_DISTANCE_THRESHOLD=1)
def test_dictionary_similarity_fail1(self):
self.url_params['password'] = 'foo'
response = self.client.post(self.url, self.url_params)
self.assertEqual(response.status_code, 400)
obj = json.loads(response.content)
self.assertEqual(
obj['value'],
"Password: Too similar to a restricted dictionary word.",
)
@override_settings(PASSWORD_DICTIONARY=['foo', 'bar'])
@override_settings(PASSWORD_DICTIONARY_EDIT_DISTANCE_THRESHOLD=1)
def test_dictionary_similarity_fail2(self):
self.url_params['password'] = 'bar'
response = self.client.post(self.url, self.url_params)
self.assertEqual(response.status_code, 400)
obj = json.loads(response.content)
self.assertEqual(
obj['value'],
"Password: Too similar to a restricted dictionary word.",
)
@override_settings(PASSWORD_DICTIONARY=['foo', 'bar'])
@override_settings(PASSWORD_DICTIONARY_EDIT_DISTANCE_THRESHOLD=1)
def test_dictionary_similarity_fail3(self):
self.url_params['password'] = 'fo0'
response = self.client.post(self.url, self.url_params)
self.assertEqual(response.status_code, 400)
obj = json.loads(response.content)
self.assertEqual(
obj['value'],
"Password: Too similar to a restricted dictionary word.",
)
@override_settings(PASSWORD_DICTIONARY=['foo', 'bar'])
@override_settings(PASSWORD_DICTIONARY_EDIT_DISTANCE_THRESHOLD=1)
def test_dictionary_similarity_pass(self):
self.url_params['password'] = 'this_is_ok'
response = self.client.post(self.url, self.url_params)
self.assertEqual(response.status_code, 200)
obj = json.loads(response.content)
self.assertTrue(obj['success'])
def test_with_unicode(self):
self.url_params['password'] = u'四節比分和七年前'
response = self.client.post(self.url, self.url_params)
self.assertEqual(response.status_code, 200)
obj = json.loads(response.content)
self.assertTrue(obj['success'])
...@@ -8,8 +8,6 @@ from terrain.stubs.youtube import StubYouTubeService ...@@ -8,8 +8,6 @@ from terrain.stubs.youtube import StubYouTubeService
from terrain.stubs.xqueue import StubXQueueService from terrain.stubs.xqueue import StubXQueueService
USAGE = "USAGE: python -m fakes.start SERVICE_NAME PORT_NUM"
SERVICES = { SERVICES = {
"youtube": {"port": settings.YOUTUBE_PORT, "class": StubYouTubeService}, "youtube": {"port": settings.YOUTUBE_PORT, "class": StubYouTubeService},
"xqueue": {"port": settings.XQUEUE_PORT, "class": StubXQueueService}, "xqueue": {"port": settings.XQUEUE_PORT, "class": StubXQueueService},
......
...@@ -57,7 +57,7 @@ def i_visit_the_dashboard(step): ...@@ -57,7 +57,7 @@ def i_visit_the_dashboard(step):
@step('I should be on the dashboard page$') @step('I should be on the dashboard page$')
def i_should_be_on_the_dashboard(step): def i_should_be_on_the_dashboard(step):
assert world.is_css_present('section.container.dashboard') assert world.is_css_present('section.container.dashboard')
assert world.browser.title == 'Dashboard' assert 'Dashboard' in world.browser.title
@step(u'I (?:visit|access|open) the courses page$') @step(u'I (?:visit|access|open) the courses page$')
......
...@@ -23,14 +23,13 @@ class StubHttpRequestHandler(BaseHTTPRequestHandler, object): ...@@ -23,14 +23,13 @@ class StubHttpRequestHandler(BaseHTTPRequestHandler, object):
""" """
Redirect messages to keep the test console clean. Redirect messages to keep the test console clean.
""" """
LOGGER.debug(self._format_msg(format_str, *args))
msg = "{0} - - [{1}] {2}\n".format( def log_error(self, format_str, *args):
self.client_address[0], """
self.log_date_time_string(), Helper to log a server error.
format_str % args """
) LOGGER.error(self._format_msg(format_str, *args))
LOGGER.debug(msg)
@lazy @lazy
def request_content(self): def request_content(self):
...@@ -76,22 +75,39 @@ class StubHttpRequestHandler(BaseHTTPRequestHandler, object): ...@@ -76,22 +75,39 @@ class StubHttpRequestHandler(BaseHTTPRequestHandler, object):
def do_PUT(self): def do_PUT(self):
""" """
Allow callers to configure the stub server using the /set_config URL. Allow callers to configure the stub server using the /set_config URL.
The request should have POST data, such that:
Each POST parameter is the configuration key.
Each POST value is a JSON-encoded string value for the configuration.
""" """
if self.path == "/set_config" or self.path == "/set_config/": if self.path == "/set_config" or self.path == "/set_config/":
for key, value in self.post_dict.iteritems(): if len(self.post_dict) > 0:
self.log_message("Set config '{0}' to '{1}'".format(key, value)) for key, value in self.post_dict.iteritems():
# Decode the params as UTF-8
try:
key = unicode(key, 'utf-8')
value = unicode(value, 'utf-8')
except UnicodeDecodeError:
self.log_message("Could not decode request params as UTF-8")
self.log_message(u"Set config '{0}' to '{1}'".format(key, value))
try: try:
value = json.loads(value) value = json.loads(value)
except ValueError: except ValueError:
self.log_message(u"Could not parse JSON: {0}".format(value)) self.log_message(u"Could not parse JSON: {0}".format(value))
self.send_response(400) self.send_response(400)
else: else:
self.server.set_config(unicode(key, 'utf-8'), value) self.server.config[key] = value
self.send_response(200) self.send_response(200)
# No parameters sent to configure, so return success by default
else:
self.send_response(200)
else: else:
self.send_response(404) self.send_response(404)
...@@ -119,6 +135,18 @@ class StubHttpRequestHandler(BaseHTTPRequestHandler, object): ...@@ -119,6 +135,18 @@ class StubHttpRequestHandler(BaseHTTPRequestHandler, object):
if content is not None: if content is not None:
self.wfile.write(content) self.wfile.write(content)
def _format_msg(self, format_str, *args):
"""
Format message for logging.
`format_str` is a string with old-style Python format escaping;
`args` is an array of values to fill into the string.
"""
return u"{0} - - [{1}] {2}\n".format(
self.client_address[0],
self.log_date_time_string(),
format_str % args
)
class StubHttpService(HTTPServer, object): class StubHttpService(HTTPServer, object):
""" """
...@@ -138,7 +166,7 @@ class StubHttpService(HTTPServer, object): ...@@ -138,7 +166,7 @@ class StubHttpService(HTTPServer, object):
HTTPServer.__init__(self, address, self.HANDLER_CLASS) HTTPServer.__init__(self, address, self.HANDLER_CLASS)
# Create a dict to store configuration values set by the client # Create a dict to store configuration values set by the client
self._config = dict() self.config = dict()
# Start the server in a separate thread # Start the server in a separate thread
server_thread = threading.Thread(target=self.serve_forever) server_thread = threading.Thread(target=self.serve_forever)
...@@ -165,17 +193,3 @@ class StubHttpService(HTTPServer, object): ...@@ -165,17 +193,3 @@ class StubHttpService(HTTPServer, object):
""" """
_, port = self.server_address _, port = self.server_address
return port return port
def config(self, key, default=None):
"""
Return the configuration value for `key`. If this
value has not been set, return `default` instead.
"""
return self._config.get(key, default)
def set_config(self, key, value):
"""
Set the configuration `value` for `key`.
"""
self._config[key] = value
"""
Command-line utility to start a stub service.
"""
import sys
import time
import logging
from .xqueue import StubXQueueService
from .youtube import StubYouTubeService
USAGE = "USAGE: python -m stubs.start SERVICE_NAME PORT_NUM"
SERVICES = {
'xqueue': StubXQueueService,
'youtube': StubYouTubeService
}
# Log to stdout, including debug messages
logging.basicConfig(level=logging.DEBUG, format="%(levelname)s %(message)s")
def get_args():
"""
Parse arguments, returning tuple of `(service_name, port_num)`.
Exits with a message if arguments are invalid.
"""
if len(sys.argv) < 3:
print USAGE
sys.exit(1)
service_name = sys.argv[1]
port_num = sys.argv[2]
if service_name not in SERVICES:
print "Unrecognized service '{0}'. Valid choices are: {1}".format(
service_name, ", ".join(SERVICES.keys()))
sys.exit(1)
try:
port_num = int(port_num)
if port_num < 0:
raise ValueError
except ValueError:
print "Port '{0}' must be a positive integer".format(port_num)
sys.exit(1)
return service_name, port_num
def main():
"""
Start a server; shut down on keyboard interrupt signal.
"""
service_name, port_num = get_args()
print "Starting stub service '{0}' on port {1}...".format(service_name, port_num)
server = SERVICES[service_name](port_num=port_num)
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
print "Stopping stub service..."
finally:
server.shutdown()
if __name__ == "__main__":
main()
...@@ -13,6 +13,7 @@ class StubHttpServiceTest(unittest.TestCase): ...@@ -13,6 +13,7 @@ class StubHttpServiceTest(unittest.TestCase):
def setUp(self): def setUp(self):
self.server = StubHttpService() self.server = StubHttpService()
self.addCleanup(self.server.shutdown) self.addCleanup(self.server.shutdown)
self.url = "http://127.0.0.1:{0}/set_config".format(self.server.port)
def test_configure(self): def test_configure(self):
""" """
...@@ -21,33 +22,38 @@ class StubHttpServiceTest(unittest.TestCase): ...@@ -21,33 +22,38 @@ class StubHttpServiceTest(unittest.TestCase):
""" """
params = { params = {
'test_str': 'This is only a test', 'test_str': 'This is only a test',
'test_empty': '',
'test_int': 12345, 'test_int': 12345,
'test_float': 123.45, 'test_float': 123.45,
'test_dict': { 'test_key': 'test_val' },
'test_empty_dict': {},
'test_unicode': u'\u2603 the snowman', 'test_unicode': u'\u2603 the snowman',
'test_dict': { 'test_key': 'test_val' } 'test_none': None,
'test_boolean': False
} }
for key, val in params.iteritems(): for key, val in params.iteritems():
post_params = {key: json.dumps(val)}
response = requests.put(
"http://127.0.0.1:{0}/set_config".format(self.server.port),
data=post_params
)
# JSON-encode each parameter
post_params = {key: json.dumps(val)}
response = requests.put(self.url, data=post_params)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
# Check that the expected values were set in the configuration # Check that the expected values were set in the configuration
for key, val in params.iteritems(): for key, val in params.iteritems():
self.assertEqual(self.server.config(key), val) self.assertEqual(self.server.config.get(key), val)
def test_default_config(self):
self.assertEqual(self.server.config('not_set', default=42), 42)
def test_bad_json(self): def test_bad_json(self):
response = requests.put( response = requests.put(self.url, data="{,}")
"http://127.0.0.1:{0}/set_config".format(self.server.port), self.assertEqual(response.status_code, 400)
data="{,}"
) def test_no_post_data(self):
response = requests.put(self.url, data={})
self.assertEqual(response.status_code, 200)
def test_unicode_non_json(self):
# Send unicode without json-encoding it
response = requests.put(self.url, data={'test_unicode': u'\u2603 the snowman'})
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
def test_unknown_path(self): def test_unknown_path(self):
......
...@@ -5,70 +5,172 @@ Unit tests for stub XQueue implementation. ...@@ -5,70 +5,172 @@ Unit tests for stub XQueue implementation.
import mock import mock
import unittest import unittest
import json import json
import urllib import requests
import time import time
from terrain.stubs.xqueue import StubXQueueService import copy
from terrain.stubs.xqueue import StubXQueueService, StubXQueueHandler
class StubXQueueServiceTest(unittest.TestCase): class StubXQueueServiceTest(unittest.TestCase):
def setUp(self): def setUp(self):
self.server = StubXQueueService() self.server = StubXQueueService()
self.url = "http://127.0.0.1:{0}".format(self.server.port) self.url = "http://127.0.0.1:{0}/xqueue/submit".format(self.server.port)
self.addCleanup(self.server.shutdown) self.addCleanup(self.server.shutdown)
# For testing purposes, do not delay the grading response # For testing purposes, do not delay the grading response
self.server.set_config('response_delay', 0) self.server.config['response_delay'] = 0
@mock.patch('requests.post') @mock.patch('terrain.stubs.xqueue.post')
def test_grade_request(self, post): def test_grade_request(self, post):
# Send a grade request # Post a submission to the stub XQueue
callback_url = 'http://127.0.0.1:8000/test_callback' callback_url = 'http://127.0.0.1:8000/test_callback'
expected_header = self._post_submission(
callback_url, 'test_queuekey', 'test_queue',
json.dumps({
'student_info': 'test',
'grader_payload': 'test',
'student_response': 'test'
})
)
grade_header = json.dumps({ # Check the response we receive
'lms_callback_url': callback_url, # (Should be the default grading response)
'lms_key': 'test_queuekey', expected_body = json.dumps({'correct': True, 'score': 1, 'msg': '<div></div>'})
'queue_name': 'test_queue' self._check_grade_response(post, callback_url, expected_header, expected_body)
})
grade_body = json.dumps({ @mock.patch('terrain.stubs.xqueue.post')
'student_info': 'test', def test_configure_default_response(self, post):
'grader_payload': 'test',
'student_response': 'test'
})
grade_request = { # Configure the default response for submissions to any queue
'xqueue_header': grade_header, response_content = {'test_response': 'test_content'}
'xqueue_body': grade_body self.server.config['default'] = response_content
}
response_handle = urllib.urlopen( # Post a submission to the stub XQueue
self.url + '/xqueue/submit', callback_url = 'http://127.0.0.1:8000/test_callback'
urllib.urlencode(grade_request) expected_header = self._post_submission(
callback_url, 'test_queuekey', 'test_queue',
json.dumps({
'student_info': 'test',
'grader_payload': 'test',
'student_response': 'test'
})
) )
response_dict = json.loads(response_handle.read()) # Check the response we receive
# (Should be the default grading response)
self._check_grade_response(
post, callback_url, expected_header, json.dumps(response_content)
)
# Expect that the response is success @mock.patch('terrain.stubs.xqueue.post')
self.assertEqual(response_dict['return_code'], 0) def test_configure_specific_response(self, post):
# Configure the XQueue stub response to any submission to the test queue
response_content = {'test_response': 'test_content'}
self.server.config['This is only a test.'] = response_content
# Expect that the server tries to post back the grading info # Post a submission to the XQueue stub
xqueue_body = json.dumps( callback_url = 'http://127.0.0.1:8000/test_callback'
{'correct': True, 'score': 1, 'msg': '<div></div>'} expected_header = self._post_submission(
callback_url, 'test_queuekey', 'test_queue',
json.dumps({'submission': 'This is only a test.'})
) )
expected_callback_dict = { # Check that we receive the response we configured
'xqueue_header': grade_header, self._check_grade_response(
post, callback_url, expected_header, json.dumps(response_content)
)
@mock.patch('terrain.stubs.xqueue.post')
def test_multiple_response_matches(self, post):
# Configure the XQueue stub with two responses that
# match the same submission
self.server.config['test_1'] = {'response': True}
self.server.config['test_2'] = {'response': False}
with mock.patch('terrain.stubs.http.LOGGER') as logger:
# Post a submission to the XQueue stub
callback_url = 'http://127.0.0.1:8000/test_callback'
expected_header = self._post_submission(
callback_url, 'test_queuekey', 'test_queue',
json.dumps({'submission': 'test_1 and test_2'})
)
# Wait for the delayed grade response
self._wait_for_mock_called(logger.error, max_time=10)
# Expect that we do NOT receive a response
# and that an error message is logged
self.assertFalse(post.called)
self.assertTrue(logger.error.called)
def _post_submission(self, callback_url, lms_key, queue_name, xqueue_body):
"""
Post a submission to the stub XQueue implementation.
`callback_url` is the URL at which we expect to receive a grade response
`lms_key` is the authentication key sent in the header
`queue_name` is the name of the queue in which to send put the submission
`xqueue_body` is the content of the submission
Returns the header (a string) we send with the submission, which can
be used to validate the response we receive from the stub.
"""
# Post a submission to the XQueue stub
grade_request = {
'xqueue_header': json.dumps({
'lms_callback_url': callback_url,
'lms_key': 'test_queuekey',
'queue_name': 'test_queue'
}),
'xqueue_body': xqueue_body 'xqueue_body': xqueue_body
} }
resp = requests.post(self.url, data=grade_request)
# Expect that the response is success
self.assertEqual(resp.status_code, 200)
# Return back the header, so we can authenticate the response we receive
return grade_request['xqueue_header']
def _check_grade_response(self, post_mock, callback_url, expected_header, expected_body):
"""
Verify that the stub sent a POST request back to us
with the expected data.
`post_mock` is our mock for `requests.post`
`callback_url` is the URL we expect the stub to POST to
`expected_header` is the header (a string) we expect to receive with the grade.
`expected_body` is the content (a string) we expect to receive with the grade.
Raises an `AssertionError` if the check fails.
"""
# Wait for the server to POST back to the callback URL # Wait for the server to POST back to the callback URL
# Time out if it takes too long # If it takes too long, continue anyway
start_time = time.time() self._wait_for_mock_called(post_mock, max_time=10)
while time.time() - start_time < 5:
if post.called: # Check the response posted back to us
break # This is the default response
expected_callback_dict = {
'xqueue_header': expected_header,
'xqueue_body': expected_body,
}
# Check that the POST request was made with the correct params # Check that the POST request was made with the correct params
post.assert_called_with(callback_url, data=expected_callback_dict) post_mock.assert_called_with(callback_url, data=expected_callback_dict)
def _wait_for_mock_called(self, mock_obj, max_time=10):
"""
Wait for `mock` (a `Mock` object) to be called.
If seconds elapsed exceeds `max_time`, continue without error.
"""
start_time = time.time()
while time.time() - start_time < max_time:
if mock_obj.called:
break
time.sleep(1)
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
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