diff --git a/.gitignore b/.gitignore index eb1c8904f8..3ddab93528 100644 --- a/.gitignore +++ b/.gitignore @@ -30,14 +30,14 @@ codekit-config.json ### Internationalization artifacts *.mo +*.po +!django.po +!django.mo +!djangojs.po +!djangojs.mo conf/locale/en/LC_MESSAGES/*.po -!messages.po -### Remove when we have real Esperanto translations. For now, ignore -### dummy Esperanto files. -conf/locale/eo/* -## Remove when we officially support these languages. -conf/locale/fr -conf/locale/ko_KR +conf/locale/en/LC_MESSAGES/*.mo +conf/locale/messages.mo ### Testing artifacts .testids/ @@ -49,6 +49,7 @@ coverage.xml cover/ cover_html/ reports/ +jscover.log jscover.log.* ### Installation artifacts diff --git a/.tx/config b/.tx/config index fd12506f17..4f1355038a 100644 --- a/.tx/config +++ b/.tx/config @@ -13,9 +13,9 @@ source_file = conf/locale/en/LC_MESSAGES/django-studio.po source_lang = en type = PO -[edx-platform.djangojs] -file_filter = conf/locale//LC_MESSAGES/djangojs.po -source_file = conf/locale/en/LC_MESSAGES/djangojs.po +[edx-platform.djangojs-partial] +file_filter = conf/locale//LC_MESSAGES/djangojs-partial.po +source_file = conf/locale/en/LC_MESSAGES/djangojs-partial.po source_lang = en type = PO diff --git a/AUTHORS b/AUTHORS index 7ad3693b78..19607b1251 100644 --- a/AUTHORS +++ b/AUTHORS @@ -105,4 +105,4 @@ Yihua Lou Andy Armstrong Matt Drayer Cristian Salamea - +Graham Lowe diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 501baee768..fc52c5156e 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -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 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". BLD-708. @@ -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. +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: Video player persist speed preferences between videos. BLD-237. @@ -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 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 Settings. The key is due_date_display_format, and the value should be a format supported by Python's strftime function. diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 46808ab4bb..8f5c97a229 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -186,7 +186,7 @@ By opening up a pull request, we expect the following things: unable to participate in the review process. 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 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 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 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%. Adjust your code appropriately if these metrics are not high enough. Be sure to @@ -307,7 +307,7 @@ commits, and comments. .. _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 .. _IRC channel: http://www.irchelp.org/irchelp/new2irc.html .. _pull request 1322: https://github.com/edx/edx-platform/pull/1322 diff --git a/cms/djangoapps/contentstore/context_processors.py b/cms/djangoapps/contentstore/context_processors.py new file mode 100644 index 0000000000..66ba685661 --- /dev/null +++ b/cms/djangoapps/contentstore/context_processors.py @@ -0,0 +1,20 @@ +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} diff --git a/cms/djangoapps/contentstore/features/component.feature b/cms/djangoapps/contentstore/features/component.feature index 5df49e59fd..90b8a8b843 100644 --- a/cms/djangoapps/contentstore/features/component.feature +++ b/cms/djangoapps/contentstore/features/component.feature @@ -112,3 +112,19 @@ Feature: CMS.Component Adding 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 "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" diff --git a/cms/djangoapps/contentstore/features/component.py b/cms/djangoapps/contentstore/features/component.py index cfe4053b2f..59a1a301b5 100644 --- a/cms/djangoapps/contentstore/features/component.py +++ b/cms/djangoapps/contentstore/features/component.py @@ -8,6 +8,8 @@ from lettuce import world, step 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:$') def add_a_single_step_component(step): @@ -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()) 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) diff --git a/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py b/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py index d3c0beb284..46c7da2411 100644 --- a/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py +++ b/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py @@ -5,6 +5,7 @@ from lettuce import world from nose.tools import assert_equal, assert_in # pylint: disable=E0611 from terrain.steps import reload_the_page from common import type_in_codemirror +from selenium.webdriver.common.keys import Keys @world.absorb @@ -219,3 +220,18 @@ def get_setting_entry_index(label): return index return None 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) diff --git a/cms/djangoapps/contentstore/features/problem-editor.py b/cms/djangoapps/contentstore/features/problem-editor.py index 2265b5010e..b52fdc6ed6 100644 --- a/cms/djangoapps/contentstore/features/problem-editor.py +++ b/cms/djangoapps/contentstore/features/problem-editor.py @@ -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 advanced_settings import change_value from course_import import import_file, go_to_import -from selenium.webdriver.common.keys import Keys DISPLAY_NAME = "Display Name" MAXIMUM_ATTEMPTS = "Maximum Attempts" @@ -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 # (to confirm that we don't throw an error because it is of the wrong type). 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() @@ -66,7 +65,7 @@ def my_display_name_change_is_persisted_on_save(step): @step('I can specify special characters in the display name') def i_can_modify_the_display_name_with_special_chars(_step): 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() @@ -141,7 +140,7 @@ def set_the_max_attempts(step, max_attempts_set): # on firefox with selenium, the behaviour is different. # eg 2.34 displays as 2.34 and is persisted as 2 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) value = world.css_value('input.setting-input', index=index) assert value != "", "max attempts is blank" @@ -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) -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): index = world.get_setting_entry_index(PROBLEM_WEIGHT) - set_field_value(index, weight) + world.set_field_value(index, weight) def open_high_level_source(): diff --git a/cms/djangoapps/contentstore/features/transcripts.feature b/cms/djangoapps/contentstore/features/transcripts.feature index 1d003bddbc..7121143456 100644 --- a/cms/djangoapps/contentstore/features/transcripts.feature +++ b/cms/djangoapps/contentstore/features/transcripts.feature @@ -146,20 +146,21 @@ Feature: CMS.Transcripts Then I see status message "found" And I see value "t_not_exist" in the field "HTML5 Transcript" + # Disabled 1/29/14 due to flakiness observed in master #10 - 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 - And I edit the component - - And I enter a "http://youtu.be/t__eq_exist" source to field number 1 - Then I see status message "not found" - And I see button "import" - And I click transcript button "import" - Then I see status message "found" - - And I enter a "t_not_exist.mp4" source to field number 2 - Then I see status message "found" - And I see value "t__eq_exist" in the field "HTML5 Transcript" + #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 + # And I edit the component + # + # And I enter a "http://youtu.be/t__eq_exist" source to field number 1 + # Then I see status message "not found" + # And I see button "import" + # And I click transcript button "import" + # Then I see status message "found" + # + # And I enter a "t_not_exist.mp4" source to field number 2 + # Then I see status message "found" + # And I see value "t__eq_exist" in the field "HTML5 Transcript" #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 diff --git a/cms/djangoapps/contentstore/management/commands/export_convert_format.py b/cms/djangoapps/contentstore/management/commands/export_convert_format.py new file mode 100644 index 0000000000..0ab8c6b3b6 --- /dev/null +++ b/cms/djangoapps/contentstore/management/commands/export_convert_format.py @@ -0,0 +1,71 @@ +""" +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 = ' ' + + def handle(self, *args, **options): + "Execute the command" + if len(args) != 2: + raise CommandError("export requires two arguments: ") + + 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) diff --git a/cms/djangoapps/contentstore/management/commands/migrate_to_split.py b/cms/djangoapps/contentstore/management/commands/migrate_to_split.py new file mode 100644 index 0000000000..3ef8dd79fb --- /dev/null +++ b/cms/djangoapps/contentstore/management/commands/migrate_to_split.py @@ -0,0 +1,74 @@ +""" +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 " + + 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) diff --git a/cms/djangoapps/contentstore/management/commands/rollback_split_course.py b/cms/djangoapps/contentstore/management/commands/rollback_split_course.py new file mode 100644 index 0000000000..3681ebf282 --- /dev/null +++ b/cms/djangoapps/contentstore/management/commands/rollback_split_course.py @@ -0,0 +1,51 @@ +""" +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.' + ) diff --git a/cms/djangoapps/contentstore/management/commands/tests/data/Version0_drafts.tar.gz b/cms/djangoapps/contentstore/management/commands/tests/data/Version0_drafts.tar.gz new file mode 100644 index 0000000000..e55649b1da Binary files /dev/null and b/cms/djangoapps/contentstore/management/commands/tests/data/Version0_drafts.tar.gz differ diff --git a/cms/djangoapps/contentstore/management/commands/tests/data/Version1_drafts.tar.gz b/cms/djangoapps/contentstore/management/commands/tests/data/Version1_drafts.tar.gz new file mode 100644 index 0000000000..4cf81903c8 Binary files /dev/null and b/cms/djangoapps/contentstore/management/commands/tests/data/Version1_drafts.tar.gz differ diff --git a/cms/djangoapps/contentstore/management/commands/tests/test_export_convert_format.py b/cms/djangoapps/contentstore/management/commands/tests/test_export_convert_format.py new file mode 100644 index 0000000000..83b70951d6 --- /dev/null +++ b/cms/djangoapps/contentstore/management/commands/tests/test_export_convert_format.py @@ -0,0 +1,65 @@ +""" +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) diff --git a/cms/djangoapps/contentstore/management/commands/tests/test_migrate_to_split.py b/cms/djangoapps/contentstore/management/commands/tests/test_migrate_to_split.py new file mode 100644 index 0000000000..215bb9d9aa --- /dev/null +++ b/cms/djangoapps/contentstore/management/commands/tests/test_migrate_to_split.py @@ -0,0 +1,89 @@ +""" +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) diff --git a/cms/djangoapps/contentstore/management/commands/tests/test_rollback_split_course.py b/cms/djangoapps/contentstore/management/commands/tests/test_rollback_split_course.py new file mode 100644 index 0000000000..98b1ea807e --- /dev/null +++ b/cms/djangoapps/contentstore/management/commands/tests/test_rollback_split_course.py @@ -0,0 +1,110 @@ +""" +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()) + diff --git a/cms/djangoapps/contentstore/tests/test_access.py b/cms/djangoapps/contentstore/tests/test_access.py new file mode 100644 index 0000000000..63441d60f9 --- /dev/null +++ b/cms/djangoapps/contentstore/tests/test_access.py @@ -0,0 +1,42 @@ +""" +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) + ) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 3cd0ba90b1..d25ffe0376 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -133,7 +133,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): # just pick one vertical 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')) self.assertEqual(resp.status_code, 200) _test_no_locations(self, resp) @@ -144,12 +144,11 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): 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 # response HTML - self.check_components_on_page(ADVANCED_COMPONENT_TYPES, ['Word cloud', - 'Annotation', - 'Text Annotation', - 'Video Annotation', - 'Open Response Assessment', - 'Peer Grading Interface']) + self.check_components_on_page( + ADVANCED_COMPONENT_TYPES, + ['Word cloud', 'Annotation', 'Text Annotation', 'Video Annotation', + 'Open Response Assessment', 'Peer Grading Interface'], + ) def test_advanced_components_require_two_clicks(self): self.check_components_on_page(['word_cloud'], ['Word cloud']) @@ -161,7 +160,8 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): # just pick one vertical descriptor = store.get_items(Location('i4x', 'edX', 'simple', 'vertical', None, None))[0] 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')) self.assertEqual(resp.status_code, 400) @@ -449,7 +449,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): """ Returns the locator for a given tab. """ tab_location = 'i4x://edX/999/static_tab/{0}'.format(tab['url_slug']) 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): @@ -457,7 +457,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): module_store = modulestore('direct') CourseFactory.create(org='edX', course='999', display_name='Robot Super Course') 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( parent_location=course_location, @@ -512,7 +512,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): # also try a custom response which will trigger the 'is this course in whitelist' logic 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')) self.assertEqual(resp.status_code, 200) @@ -534,7 +534,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): # make sure the parent points to the child object which is to be deleted 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}) found = False @@ -685,7 +685,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): # go through the website to do the delete, since the soft-delete logic is in the view 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') resp = self.client.delete(url) self.assertEqual(resp.status_code, 204) @@ -1062,7 +1062,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): ) # 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)) self._check_verticals(items, course_location.course_id) @@ -1353,7 +1353,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): # Assert is here to make sure that the course being tested actually has verticals (units) to check. self.assertGreater(len(items), 0) 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')) self.assertEqual(resp.status_code, 200) _test_no_locations(self, resp) @@ -1645,7 +1645,7 @@ class ContentStoreTest(ModuleStoreTestCase): import_from_xml(modulestore('direct'), 'common/test/data/', ['simple']) 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) self.assertEqual(resp.status_code, 200) @@ -1666,14 +1666,14 @@ class ContentStoreTest(ModuleStoreTestCase): # go look at a subsection page 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')) self.assertEqual(resp.status_code, 200) _test_no_locations(self, resp) # go look at the Edit page 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')) self.assertEqual(resp.status_code, 200) _test_no_locations(self, resp) @@ -1681,7 +1681,7 @@ class ContentStoreTest(ModuleStoreTestCase): def delete_item(category, name): """ Helper method for testing the deletion of an xblock item. """ 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')) self.assertEqual(resp.status_code, 204) _test_no_locations(self, resp, status_code=204, html=False) @@ -1883,7 +1883,7 @@ class ContentStoreTest(ModuleStoreTestCase): """ 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/', '')) _test_no_locations(self, resp) return resp @@ -1998,7 +1998,7 @@ def _course_factory_create_course(): Creates a course via the CourseFactory and returns the locator for it. """ 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): diff --git a/cms/djangoapps/contentstore/tests/tests.py b/cms/djangoapps/contentstore/tests/tests.py index 691239903e..bc8c2aa8fa 100644 --- a/cms/djangoapps/contentstore/tests/tests.py +++ b/cms/djangoapps/contentstore/tests/tests.py @@ -2,6 +2,7 @@ This test file will test registration, login, activation, and session activity timeouts """ import time +import mock from django.test.utils import override_settings from django.core.cache import cache @@ -16,6 +17,7 @@ from contentstore.tests.modulestore_config import TEST_MODULESTORE import datetime from pytz import UTC +from freezegun import freeze_time @override_settings(MODULESTORE=TEST_MODULESTORE) class ContentStoreTestCase(ModuleStoreTestCase): @@ -109,7 +111,7 @@ class AuthTestCase(ContentStoreTestCase): def test_create_account_errors(self): # No post data -- should fail resp = self.client.post('/create_account', {}) - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 400) data = parse_json(resp) self.assertEqual(data['success'], False) @@ -142,6 +144,53 @@ class AuthTestCase(ContentStoreTestCase): self.assertFalse(data['success']) 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): 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 diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index f5b3196ccb..fbe04186c4 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -143,7 +143,7 @@ def get_lms_link_for_item(location, preview=False, course_id=None): else: 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, course_id=course_id, location=Location(location) @@ -179,7 +179,7 @@ def get_lms_link_for_about_page(location): about_base = 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, course_id=Location(location).course_id ) diff --git a/cms/djangoapps/contentstore/views/access.py b/cms/djangoapps/contentstore/views/access.py index 215c8becf2..9ff328a898 100644 --- a/cms/djangoapps/contentstore/views/access.py +++ b/cms/djangoapps/contentstore/views/access.py @@ -1,6 +1,6 @@ from ..utils import get_course_location_for_item from xmodule.modulestore.locator import CourseLocator -from student.roles import CourseStaffRole, GlobalStaff +from student.roles import CourseStaffRole, GlobalStaff, CourseInstructorRole from student import auth @@ -20,3 +20,17 @@ def has_course_access(user, location, role=CourseStaffRole): # this can be expensive if location is not category=='course' location = get_course_location_for_item(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' diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index d466bbe732..8c1bcb7a9d 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -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_link = ( - '//{preview_lms_base}/courses/{org}/{course}/' - '{course_name}/courseware/{section}/{subsection}/{index}' + u'//{preview_lms_base}/courses/{org}/{course}/{course_name}/courseware/{section}/{subsection}/{index}' ).format( preview_lms_base=preview_lms_base, lms_base=settings.LMS_BASE, diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 89c5a33db7..e930e33c1a 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -251,7 +251,7 @@ def create_new_course(request): run = request.json.get('run') try: - dest_location = Location('i4x', org, number, 'course', run) + dest_location = Location(u'i4x', org, number, u'course', run) except InvalidLocationError as error: return JsonResponse({ "ErrMsg": _("Unable to create course '{name}'.\n\n{err}").format( @@ -286,8 +286,10 @@ def create_new_course(request): course_search_location = bson.son.SON({ '_id.tag': 'i4x', # cannot pass regex to Location constructor; thus this hack - '_id.org': re.compile('^{}$'.format(dest_location.org), re.IGNORECASE), - '_id.course': re.compile('^{}$'.format(dest_location.course), re.IGNORECASE), + # pylint: disable=E1101 + '_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', }) courses = modulestore().collection.find(course_search_location, fields=('_id')) diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index 116fa33894..3d8f81a6ef 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -113,7 +113,8 @@ def xblock_handler(request, tag=None, package_id=None, branch=None, version_guid return render_to_response('component.html', { 'preview': get_preview_html(request, component), - 'editor': content + 'editor': content, + 'label': component.display_name or component.category, }) elif request.method == 'DELETE': delete_children = str_to_bool(request.REQUEST.get('recurse', 'False')) diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py index 3a8cba68f2..11bea40f9f 100644 --- a/cms/djangoapps/contentstore/views/preview.py +++ b/cms/djangoapps/contentstore/views/preview.py @@ -26,6 +26,8 @@ from .session_kv_store import SessionKeyValueStore from .helpers import render_from_lms from ..utils import get_course_for_item +from contentstore.views.access import get_user_role + __all__ = ['preview_handler'] log = logging.getLogger(__name__) @@ -39,7 +41,7 @@ def handler_prefix(block, handler='', suffix=''): Trailing `/`s are removed from the returned url. """ 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, 'suffix': suffix, }).rstrip('/?') @@ -132,6 +134,7 @@ def _preview_module_system(request, descriptor): ), ), error_descriptor_class=ErrorDescriptor, + get_user_role=lambda: get_user_role(request.user, descriptor.location, course_id), ) diff --git a/cms/envs/aws.py b/cms/envs/aws.py index a781576455..3a5ea49389 100644 --- a/cms/envs/aws.py +++ b/cms/envs/aws.py @@ -153,6 +153,11 @@ COURSES_WITH_UNSAFE_CODE = ENV_TOKENS.get("COURSES_WITH_UNSAFE_CODE", []) #Timezone overrides 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', {})) for feature, value in ENV_FEATURES.items(): FEATURES[feature] = value @@ -224,6 +229,11 @@ TRACKING_BACKENDS.update(AUTH_TOKENS.get("TRACKING_BACKENDS", {})) SUBDOMAIN_BRANDING = ENV_TOKENS.get('SUBDOMAIN_BRANDING', {}) 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_ROOT_DIR = ENV_TOKENS.get('MICROSITE_ROOT_DIR') if len(MICROSITE_CONFIGURATION.keys()) > 0: @@ -233,3 +243,13 @@ if len(MICROSITE_CONFIGURATION.keys()) > 0: VIRTUAL_UNIVERSITIES, 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") diff --git a/cms/envs/common.py b/cms/envs/common.py index c96b171959..9a83610efe 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- """ 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, @@ -63,9 +64,15 @@ FEATURES = { # edX has explicitly added them to the course creator group. '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 # to just those pre-approved by edX '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 @@ -113,9 +120,11 @@ TEMPLATE_CONTEXT_PROCESSORS = ( 'django.core.context_processors.request', 'django.core.context_processors.static', 'django.contrib.messages.context_processors.messages', + 'django.core.context_processors.i18n', 'django.contrib.auth.context_processors.auth', # this is required for admin 'django.core.context_processors.csrf', 'dealer.contrib.django.staff.context_processor', # access git revision + 'contentstore.context_processors.doc_url', ) # use the ratelimit backend to prevent brute force attacks @@ -165,12 +174,16 @@ MIDDLEWARE_CLASSES = ( 'django.contrib.messages.middleware.MessageMiddleware', '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 'django.middleware.locale.LocaleMiddleware', '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 'ratelimitbackend.middleware.RateLimitMiddleware', @@ -242,12 +255,8 @@ STATICFILES_DIRS = [ 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 -# We want i18n to be turned off in production, at least until we have full localizations. -# Thus we want the Django translation engine to be disabled. Otherwise even without -# 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 +LANGUAGES = lms.envs.common.LANGUAGES +USE_I18N = True USE_L10N = True # Localization strings (e.g. django.po) are under this directory @@ -404,6 +413,9 @@ INSTALLED_APPS = ( 'south', 'method_override', + # Database-backed configuration + 'config_models', + # Monitor the status of services 'service_status', @@ -436,7 +448,12 @@ INSTALLED_APPS = ( 'django.contrib.admin', # for managing course modes - 'course_modes' + 'course_modes', + + # Dark-launching languages + 'dark_lang', + # Student identity reverification + 'reverification', ) @@ -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 # names/passwords. Heartbeat events are likely not interesting. TRACKING_IGNORE_URL_PATTERNS = [r'^/event', r'^/login', r'^/heartbeat'] @@ -474,3 +499,8 @@ YOUTUBE_API = { 'url': "http://video.google.com/timedtext", '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 diff --git a/cms/envs/dev.py b/cms/envs/dev.py index 16efed18c8..cd3e2067f4 100644 --- a/cms/envs/dev.py +++ b/cms/envs/dev.py @@ -9,11 +9,6 @@ from .common import * from logsettings import get_logger_config DEBUG = True -USE_I18N = True -# For displaying the dummy text, we need to provide a language mapping. -LANGUAGES = ( - ('eo', 'Esperanto'), -) TEMPLATE_DEBUG = DEBUG LOGGING = get_logger_config(ENV_ROOT / "log", logging_env="dev", diff --git a/cms/static/js/index.js b/cms/static/js/index.js index d8a6903763..fff1923f4d 100644 --- a/cms/static/js/index.js +++ b/cms/static/js/index.js @@ -84,8 +84,8 @@ require(["domReady", "jquery", "underscore", "js/utils/cancel_on_escape"], if (required) { return required; } - if (item !== encodeURIComponent(item)) { - return gettext('Please do not use any spaces or special characters in this field.'); + if (/\s/g.test(item)) { + return gettext('Please do not use any spaces in this field.'); } return ''; }; diff --git a/cms/static/sass/views/_unit.scss b/cms/static/sass/views/_unit.scss index 5795c59d2e..1cd74e12a1 100644 --- a/cms/static/sass/views/_unit.scss +++ b/cms/static/sass/views/_unit.scss @@ -881,19 +881,23 @@ body.unit { padding: $baseline/4 $baseline/2; top: 0; left: 0; - border-bottom: 1px solid $gray-l4; - background: $gray-l5; } .component-header { display: inline-block; - width: 50%; - vertical-align: middle; + overflow: hidden; + padding: $baseline/2 0px 0px $baseline/4; + max-width: 60%; + color: $gray-l1; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 300; } .component-actions { display: inline-block; float: right; + max-width: 40%; vertical-align: middle; text-align: center; } @@ -934,6 +938,7 @@ body.unit { .action-button-text { padding-left: 1px; vertical-align: bottom; + text-transform: uppercase; line-height: 17px; } diff --git a/cms/templates/base.html b/cms/templates/base.html index 6f87c4c5a9..625777fe23 100644 --- a/cms/templates/base.html +++ b/cms/templates/base.html @@ -3,10 +3,10 @@ <%namespace name='static' file='static_content.html'/> - - - - + + + + @@ -32,7 +32,7 @@ <%block name="header_extras"> - + ${_("Skip to this view's content")} diff --git a/cms/templates/unit.html b/cms/templates/unit.html index 1151b4c552..04d73d8efd 100644 --- a/cms/templates/unit.html +++ b/cms/templates/unit.html @@ -164,7 +164,7 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit" % endif ${_("with the subsection {link_start}{name}{link_end}").format( name=subsection.display_name_with_default, - link_start=''.format(url=subsection_url), + link_start=u''.format(url=subsection_url), link_end='', )}

diff --git a/cms/urls.py b/cms/urls.py index 8b6b18ef71..d21f0a2222 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -42,7 +42,7 @@ urlpatterns = patterns('', # nopep8 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[^/]*)$', 'student.views.activate_account', name='activate'), # ajax view that actually does the work diff --git a/common/djangoapps/cache_toolbox/core.py b/common/djangoapps/cache_toolbox/core.py index 9a7be940b8..3321f83d5e 100644 --- a/common/djangoapps/cache_toolbox/core.py +++ b/common/djangoapps/cache_toolbox/core.py @@ -110,12 +110,12 @@ def instance_key(model, instance_or_pk): 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): - return cache.get(str(location)) + return cache.get(unicode(location).encode("utf-8")) def del_cached_content(location): - cache.delete(str(location)) + cache.delete(unicode(location).encode("utf-8")) diff --git a/common/djangoapps/config_models/README.rst b/common/djangoapps/config_models/README.rst new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/djangoapps/config_models/__init__.py b/common/djangoapps/config_models/__init__.py new file mode 100644 index 0000000000..3f71f1c98e --- /dev/null +++ b/common/djangoapps/config_models/__init__.py @@ -0,0 +1,62 @@ +""" +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. +""" diff --git a/common/djangoapps/config_models/admin.py b/common/djangoapps/config_models/admin.py new file mode 100644 index 0000000000..378900f1dc --- /dev/null +++ b/common/djangoapps/config_models/admin.py @@ -0,0 +1,80 @@ +""" +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,), + ) + ) diff --git a/common/djangoapps/config_models/models.py b/common/djangoapps/config_models/models.py new file mode 100644 index 0000000000..3c1d5d6061 --- /dev/null +++ b/common/djangoapps/config_models/models.py @@ -0,0 +1,62 @@ +""" +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 diff --git a/common/djangoapps/config_models/templatetags.py b/common/djangoapps/config_models/templatetags.py new file mode 100644 index 0000000000..8641fd11ea --- /dev/null +++ b/common/djangoapps/config_models/templatetags.py @@ -0,0 +1,29 @@ +""" +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 diff --git a/common/djangoapps/config_models/tests.py b/common/djangoapps/config_models/tests.py new file mode 100644 index 0000000000..bb14ad18e8 --- /dev/null +++ b/common/djangoapps/config_models/tests.py @@ -0,0 +1,76 @@ +""" +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) diff --git a/common/djangoapps/course_groups/cohorts.py b/common/djangoapps/course_groups/cohorts.py index d2c7e3a782..c6b93e5634 100644 --- a/common/djangoapps/course_groups/cohorts.py +++ b/common/djangoapps/course_groups/cohorts.py @@ -77,7 +77,7 @@ def is_commentable_cohorted(course_id, commentable_id): # inline discussions are cohorted by default 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, ans)) return ans diff --git a/common/djangoapps/dark_lang/__init__.py b/common/djangoapps/dark_lang/__init__.py new file mode 100644 index 0000000000..d56fa38068 --- /dev/null +++ b/common/djangoapps/dark_lang/__init__.py @@ -0,0 +1,19 @@ +""" +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. +""" diff --git a/common/djangoapps/dark_lang/admin.py b/common/djangoapps/dark_lang/admin.py new file mode 100644 index 0000000000..cc80e49b25 --- /dev/null +++ b/common/djangoapps/dark_lang/admin.py @@ -0,0 +1,10 @@ +""" +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) diff --git a/common/djangoapps/dark_lang/middleware.py b/common/djangoapps/dark_lang/middleware.py new file mode 100644 index 0000000000..28a12e9da5 --- /dev/null +++ b/common/djangoapps/dark_lang/middleware.py @@ -0,0 +1,92 @@ +""" +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 diff --git a/common/djangoapps/dark_lang/migrations/0001_initial.py b/common/djangoapps/dark_lang/migrations/0001_initial.py new file mode 100644 index 0000000000..cc715fe8e5 --- /dev/null +++ b/common/djangoapps/dark_lang/migrations/0001_initial.py @@ -0,0 +1,74 @@ +# -*- 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 diff --git a/common/djangoapps/dark_lang/migrations/0002_enable_on_install.py b/common/djangoapps/dark_lang/migrations/0002_enable_on_install.py new file mode 100644 index 0000000000..c794a156ce --- /dev/null +++ b/common/djangoapps/dark_lang/migrations/0002_enable_on_install.py @@ -0,0 +1,68 @@ +# -*- 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 diff --git a/common/djangoapps/dark_lang/migrations/__init__.py b/common/djangoapps/dark_lang/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/djangoapps/dark_lang/models.py b/common/djangoapps/dark_lang/models.py new file mode 100644 index 0000000000..9912287b4e --- /dev/null +++ b/common/djangoapps/dark_lang/models.py @@ -0,0 +1,26 @@ +""" +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 diff --git a/common/djangoapps/dark_lang/tests.py b/common/djangoapps/dark_lang/tests.py new file mode 100644 index 0000000000..9896851984 --- /dev/null +++ b/common/djangoapps/dark_lang/tests.py @@ -0,0 +1,210 @@ +""" +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') + ) diff --git a/common/djangoapps/edxmako/__init__.py b/common/djangoapps/edxmako/__init__.py index 007b2680ae..613342b35a 100644 --- a/common/djangoapps/edxmako/__init__.py +++ b/common/djangoapps/edxmako/__init__.py @@ -11,5 +11,6 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +LOOKUP = {} -lookup = None +from .paths import add_lookup, lookup_template diff --git a/common/djangoapps/edxmako/middleware.py b/common/djangoapps/edxmako/middleware.py index 9587c0b2b0..27b129f571 100644 --- a/common/djangoapps/edxmako/middleware.py +++ b/common/djangoapps/edxmako/middleware.py @@ -12,8 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -import ConfigParser -from django.conf import settings from django.template import RequestContext from util.request import safe_get_host requestcontext = None @@ -26,21 +24,3 @@ class MakoMiddleware(object): requestcontext = RequestContext(request) requestcontext['is_secure'] = request.is_secure() 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 diff --git a/common/djangoapps/edxmako/paths.py b/common/djangoapps/edxmako/paths.py new file mode 100644 index 0000000000..8601110ddd --- /dev/null +++ b/common/djangoapps/edxmako/paths.py @@ -0,0 +1,51 @@ +""" +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) diff --git a/common/djangoapps/edxmako/shortcuts.py b/common/djangoapps/edxmako/shortcuts.py index d70e2145dd..73f76b8afd 100644 --- a/common/djangoapps/edxmako/shortcuts.py +++ b/common/djangoapps/edxmako/shortcuts.py @@ -18,7 +18,7 @@ import logging from microsite_configuration.middleware import MicrositeConfiguration -import edxmako +from edxmako import lookup_template import edxmako.middleware from django.conf import settings from django.core.urlresolvers import reverse @@ -100,7 +100,7 @@ def render_to_string(template_name, dictionary, context=None, namespace='main'): if context: context_dictionary.update(context) # fetch and render template - template = edxmako.lookup[namespace].get_template(template_name) + template = lookup_template(namespace, template_name) return template.render_unicode(**context_dictionary) diff --git a/common/djangoapps/edxmako/startup.py b/common/djangoapps/edxmako/startup.py index 2b58deac2e..1783373239 100644 --- a/common/djangoapps/edxmako/startup.py +++ b/common/djangoapps/edxmako/startup.py @@ -1,33 +1,15 @@ """ Initialize the mako template lookup """ - -import tempdir from django.conf import settings -from mako.lookup import TemplateLookup - -import edxmako +from . import add_lookup 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 - module_directory = getattr(settings, 'MAKO_MODULE_DIR', None) - - if module_directory is None: - 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 + for namespace, directories in template_locations.items(): + for directory in directories: + add_lookup(namespace, directory) diff --git a/common/djangoapps/edxmako/template.py b/common/djangoapps/edxmako/template.py index 43ac057a27..209b4d6c4f 100644 --- a/common/djangoapps/edxmako/template.py +++ b/common/djangoapps/edxmako/template.py @@ -19,7 +19,7 @@ from edxmako.shortcuts import marketing_link import edxmako 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) @@ -34,8 +34,8 @@ class Template(MakoTemplate): def __init__(self, *args, **kwargs): """Overrides base __init__ to provide django variable overrides""" if not kwargs.get('no_django', False): - overrides = dict([(k, getattr(edxmako, k, None),) for k in django_variables]) - overrides['lookup'] = overrides['lookup']['main'] + overrides = {k: getattr(edxmako, k, None) for k in DJANGO_VARIABLES} + overrides['lookup'] = edxmako.LOOKUP['main'] kwargs.update(overrides) super(Template, self).__init__(*args, **kwargs) diff --git a/common/djangoapps/edxmako/tests.py b/common/djangoapps/edxmako/tests.py index 882d6612d4..2fc79bb348 100644 --- a/common/djangoapps/edxmako/tests.py +++ b/common/djangoapps/edxmako/tests.py @@ -1,6 +1,7 @@ from django.test import TestCase from django.test.utils import override_settings from django.core.urlresolvers import reverse +from edxmako import add_lookup, LOOKUP from edxmako.shortcuts import marketing_link from mock import patch from util.testing import UrlResetMixin @@ -24,3 +25,15 @@ class ShortcutsTests(UrlResetMixin, TestCase): expected_link = reverse('login') link = marketing_link('ABOUT') 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')) diff --git a/common/djangoapps/external_auth/tests/test_shib.py b/common/djangoapps/external_auth/tests/test_shib.py index fdcf940449..0ef912464b 100644 --- a/common/djangoapps/external_auth/tests/test_shib.py +++ b/common/djangoapps/external_auth/tests/test_shib.py @@ -202,7 +202,7 @@ class ShibSPTest(ModuleStoreTestCase): else: self.assertEqual(response.status_code, 200) self.assertContains(response, - ("Preferences for {platform_name}" + ("Preferences for {platform_name}" .format(platform_name=settings.PLATFORM_NAME))) # no audit logging calls self.assertEquals(len(audit_log_calls), 0) diff --git a/common/djangoapps/microsite_configuration/__init__.py b/common/djangoapps/microsite_configuration/__init__.py index e69de29bb2..cc081c890a 100644 --- a/common/djangoapps/microsite_configuration/__init__.py +++ b/common/djangoapps/microsite_configuration/__init__.py @@ -0,0 +1 @@ +from .templatetags.microsite import page_title_breadcrumbs diff --git a/common/djangoapps/microsite_configuration/templatetags/__init__.py b/common/djangoapps/microsite_configuration/templatetags/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/djangoapps/microsite_configuration/templatetags/microsite.py b/common/djangoapps/microsite_configuration/templatetags/microsite.py new file mode 100644 index 0000000000..5e76c152a9 --- /dev/null +++ b/common/djangoapps/microsite_configuration/templatetags/microsite.py @@ -0,0 +1,40 @@ +""" +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) diff --git a/common/djangoapps/microsite_configuration/test_microsites.py b/common/djangoapps/microsite_configuration/test_microsites.py new file mode 100644 index 0000000000..b3bb21990a --- /dev/null +++ b/common/djangoapps/microsite_configuration/test_microsites.py @@ -0,0 +1,32 @@ +# -*- 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 diff --git a/common/djangoapps/reverification/__init__.py b/common/djangoapps/reverification/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/djangoapps/reverification/admin.py b/common/djangoapps/reverification/admin.py new file mode 100644 index 0000000000..982572ad73 --- /dev/null +++ b/common/djangoapps/reverification/admin.py @@ -0,0 +1,8 @@ +""" +Reverification admin +""" + +from ratelimitbackend import admin +from reverification.models import MidcourseReverificationWindow + +admin.site.register(MidcourseReverificationWindow) diff --git a/common/djangoapps/reverification/migrations/0001_initial.py b/common/djangoapps/reverification/migrations/0001_initial.py new file mode 100644 index 0000000000..89ad801879 --- /dev/null +++ b/common/djangoapps/reverification/migrations/0001_initial.py @@ -0,0 +1,36 @@ +# -*- 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 diff --git a/common/djangoapps/reverification/migrations/__init__.py b/common/djangoapps/reverification/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/djangoapps/reverification/models.py b/common/djangoapps/reverification/models.py new file mode 100644 index 0000000000..53b2b659c9 --- /dev/null +++ b/common/djangoapps/reverification/models.py @@ -0,0 +1,54 @@ +""" +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 diff --git a/common/djangoapps/reverification/tests/__init__.py b/common/djangoapps/reverification/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/djangoapps/reverification/tests/factories.py b/common/djangoapps/reverification/tests/factories.py new file mode 100644 index 0000000000..5a0452b7f7 --- /dev/null +++ b/common/djangoapps/reverification/tests/factories.py @@ -0,0 +1,19 @@ +""" +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) diff --git a/common/djangoapps/reverification/tests/test_models.py b/common/djangoapps/reverification/tests/test_models.py new file mode 100644 index 0000000000..4f94fb5ef7 --- /dev/null +++ b/common/djangoapps/reverification/tests/test_models.py @@ -0,0 +1,73 @@ +""" +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() diff --git a/common/djangoapps/static_replace/__init__.py b/common/djangoapps/static_replace/__init__.py index a05129d864..29dcf7455b 100644 --- a/common/djangoapps/static_replace/__init__.py +++ b/common/djangoapps/static_replace/__init__.py @@ -19,7 +19,7 @@ def _url_replace_regex(prefix): To anyone contemplating making this more complicated: http://xkcd.com/1171/ """ - return r""" + return ur""" (?x) # flags=re.VERBOSE (?P\\?['"]) # the opening quotes (?P{prefix}) # the prefix @@ -152,7 +152,7 @@ def replace_static_urls(text, data_directory, course_id=None, static_asset_path= return "".join([quote, url, quote]) 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, data_dir=static_asset_path or data_directory )), diff --git a/common/djangoapps/student/forms.py b/common/djangoapps/student/forms.py index 1096092117..fdef5da3eb 100644 --- a/common/djangoapps/student/forms.py +++ b/common/djangoapps/student/forms.py @@ -1,3 +1,6 @@ +""" +Utility functions for validating forms +""" from django import forms from django.contrib.auth.models import User from django.contrib.auth.forms import PasswordResetForm diff --git a/common/djangoapps/student/management/commands/massemail.py b/common/djangoapps/student/management/commands/massemail.py index 5ce8afb41f..8e9e2ba69f 100644 --- a/common/djangoapps/student/management/commands/massemail.py +++ b/common/djangoapps/student/management/commands/massemail.py @@ -1,7 +1,7 @@ from django.core.management.base import BaseCommand from django.contrib.auth.models import User -import edxmako +from edxmako import lookup_template class Command(BaseCommand): @@ -15,8 +15,8 @@ body, and an _subject.txt for the subject. ''' #text = open(args[0]).read() #subject = open(args[1]).read() users = User.objects.all() - text = edxmako.lookup['main'].get_template('email/' + args[0] + ".txt").render() - subject = edxmako.lookup['main'].get_template('email/' + args[0] + "_subject.txt").render().strip() + text = lookup_template('main', 'email/' + args[0] + ".txt").render() + subject = lookup_template('main', 'email/' + args[0] + "_subject.txt").render().strip() for user in users: if user.is_active: user.email_user(subject, text) diff --git a/common/djangoapps/student/management/commands/massemailtxt.py b/common/djangoapps/student/management/commands/massemailtxt.py index 1ff8557a25..e3ec851745 100644 --- a/common/djangoapps/student/management/commands/massemailtxt.py +++ b/common/djangoapps/student/management/commands/massemailtxt.py @@ -4,7 +4,7 @@ import time from django.core.management.base import BaseCommand from django.conf import settings -import edxmako +from edxmako import lookup_template from django.core.mail import send_mass_mail import sys @@ -39,8 +39,8 @@ rate -- messages per second users = [u.strip() for u in open(user_file).readlines()] - message = edxmako.lookup['main'].get_template('emails/' + message_base + "_body.txt").render() - subject = edxmako.lookup['main'].get_template('emails/' + message_base + "_subject.txt").render().strip() + message = lookup_template('main', 'emails/' + message_base + "_body.txt").render() + subject = lookup_template('main', 'emails/' + message_base + "_subject.txt").render().strip() rate = int(ratestr) self.log_file = open(logfilename, "a+", buffering=0) diff --git a/common/djangoapps/student/migrations/0030_auto__chg_field_anonymoususerid_anonymous_user_id.py b/common/djangoapps/student/migrations/0030_auto__chg_field_anonymoususerid_anonymous_user_id.py index b096a9c322..f379ad6452 100644 --- a/common/djangoapps/student/migrations/0030_auto__chg_field_anonymoususerid_anonymous_user_id.py +++ b/common/djangoapps/student/migrations/0030_auto__chg_field_anonymoususerid_anonymous_user_id.py @@ -110,60 +110,6 @@ class Migration(SchemaMigration): 'id': ('django.db.models.fields.AutoField', [], {'primary_key': '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': { 'Meta': {'object_name': 'UserProfile', 'db_table': "'auth_userprofile'"}, 'allow_certificate': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), @@ -197,4 +143,4 @@ class Migration(SchemaMigration): } } - complete_apps = ['student'] \ No newline at end of file + complete_apps = ['student'] diff --git a/common/djangoapps/student/migrations/0031_drop_student_anonymoususerid_temp_archive.py b/common/djangoapps/student/migrations/0031_drop_student_anonymoususerid_temp_archive.py index ac7d0ed117..bbf31664b9 100644 --- a/common/djangoapps/student/migrations/0031_drop_student_anonymoususerid_temp_archive.py +++ b/common/djangoapps/student/migrations/0031_drop_student_anonymoususerid_temp_archive.py @@ -95,60 +95,6 @@ class Migration(DataMigration): 'id': ('django.db.models.fields.AutoField', [], {'primary_key': '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': { 'Meta': {'object_name': 'UserProfile', 'db_table': "'auth_userprofile'"}, 'allow_certificate': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), diff --git a/common/djangoapps/student/migrations/0032_add_field_UserProfile_country_add_field_UserProfile_city.py b/common/djangoapps/student/migrations/0032_add_field_UserProfile_country_add_field_UserProfile_city.py new file mode 100644 index 0000000000..bf35f208ed --- /dev/null +++ b/common/djangoapps/student/migrations/0032_add_field_UserProfile_country_add_field_UserProfile_city.py @@ -0,0 +1,146 @@ +# -*- 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 field 'UserProfile.country' + db.add_column('auth_userprofile', 'country', + self.gf('django_countries.fields.CountryField')(max_length=2, null=True, blank=True), + keep_default=False) + + # Adding field 'UserProfile.city' + db.add_column('auth_userprofile', 'city', + self.gf('django.db.models.fields.TextField')(null=True, blank=True), + keep_default=False) + + + def backwards(self, orm): + # Deleting field 'UserProfile.country' + db.delete_column('auth_userprofile', 'country') + + # Deleting field 'UserProfile.city' + db.delete_column('auth_userprofile', 'city') + + + 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'}) + }, + 'student.anonymoususerid': { + 'Meta': {'object_name': 'AnonymousUserId'}, + 'anonymous_user_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32'}), + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'student.courseenrollment': { + 'Meta': {'ordering': "('user', 'course_id')", 'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '100'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'student.courseenrollmentallowed': { + 'Meta': {'unique_together': "(('email', 'course_id'),)", 'object_name': 'CourseEnrollmentAllowed'}, + 'auto_enroll': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + 'student.pendingemailchange': { + 'Meta': {'object_name': 'PendingEmailChange'}, + 'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'new_email': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.pendingnamechange': { + 'Meta': {'object_name': 'PendingNameChange'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'new_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'rationale': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.registration': { + 'Meta': {'object_name': 'Registration', 'db_table': "'auth_registration'"}, + 'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.userprofile': { + 'Meta': {'object_name': 'UserProfile', 'db_table': "'auth_userprofile'"}, + 'allow_certificate': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'city': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'country': ('django_countries.fields.CountryField', [], {'max_length': '2', 'null': 'True', 'blank': 'True'}), + 'courseware': ('django.db.models.fields.CharField', [], {'default': "'course.xml'", 'max_length': '255', 'blank': 'True'}), + 'gender': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}), + 'goals': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'language': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'level_of_education': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}), + 'location': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'mailing_address': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'meta': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'profile'", 'unique': 'True', 'to': "orm['auth.User']"}), + 'year_of_birth': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}) + }, + 'student.userstanding': { + 'Meta': {'object_name': 'UserStanding'}, + 'account_status': ('django.db.models.fields.CharField', [], {'max_length': '31', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'standing_last_changed_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'standing'", 'unique': 'True', 'to': "orm['auth.User']"}) + }, + 'student.usertestgroup': { + 'Meta': {'object_name': 'UserTestGroup'}, + 'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}), + 'users': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'db_index': 'True', 'symmetrical': 'False'}) + } + } + + complete_apps = ['student'] \ No newline at end of file diff --git a/common/djangoapps/student/migrations/0032_auto__add_loginfailures.py b/common/djangoapps/student/migrations/0032_auto__add_loginfailures.py new file mode 100644 index 0000000000..c39d2595be --- /dev/null +++ b/common/djangoapps/student/migrations/0032_auto__add_loginfailures.py @@ -0,0 +1,147 @@ +# -*- 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 'LoginFailures' + db.create_table('student_loginfailures', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])), + ('failure_count', self.gf('django.db.models.fields.IntegerField')(default=0)), + ('lockout_until', self.gf('django.db.models.fields.DateTimeField')(null=True)), + )) + db.send_create_signal('student', ['LoginFailures']) + + + def backwards(self, orm): + # Deleting model 'LoginFailures' + db.delete_table('student_loginfailures') + + + 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'}) + }, + 'student.anonymoususerid': { + 'Meta': {'object_name': 'AnonymousUserId'}, + 'anonymous_user_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32'}), + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'student.courseenrollment': { + 'Meta': {'ordering': "('user', 'course_id')", 'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '100'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'student.courseenrollmentallowed': { + 'Meta': {'unique_together': "(('email', 'course_id'),)", 'object_name': 'CourseEnrollmentAllowed'}, + 'auto_enroll': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + 'student.loginfailures': { + 'Meta': {'object_name': 'LoginFailures'}, + 'failure_count': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'lockout_until': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'student.pendingemailchange': { + 'Meta': {'object_name': 'PendingEmailChange'}, + 'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'new_email': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.pendingnamechange': { + 'Meta': {'object_name': 'PendingNameChange'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'new_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'rationale': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.registration': { + 'Meta': {'object_name': 'Registration', 'db_table': "'auth_registration'"}, + 'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.userprofile': { + 'Meta': {'object_name': 'UserProfile', 'db_table': "'auth_userprofile'"}, + 'allow_certificate': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'courseware': ('django.db.models.fields.CharField', [], {'default': "'course.xml'", 'max_length': '255', 'blank': 'True'}), + 'gender': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}), + 'goals': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'language': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'level_of_education': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}), + 'location': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'mailing_address': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'meta': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'profile'", 'unique': 'True', 'to': "orm['auth.User']"}), + 'year_of_birth': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}) + }, + 'student.userstanding': { + 'Meta': {'object_name': 'UserStanding'}, + 'account_status': ('django.db.models.fields.CharField', [], {'max_length': '31', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'standing_last_changed_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'standing'", 'unique': 'True', 'to': "orm['auth.User']"}) + }, + 'student.usertestgroup': { + 'Meta': {'object_name': 'UserTestGroup'}, + 'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}), + 'users': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'db_index': 'True', 'symmetrical': 'False'}) + } + } + + complete_apps = ['student'] \ No newline at end of file diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index a074d61cb9..62d9dc1aa8 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -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/ """ import crum -from datetime import datetime +from datetime import datetime, timedelta import hashlib import json import logging @@ -29,6 +29,7 @@ from django.dispatch import receiver, Signal import django.dispatch from django.forms import ModelForm, forms from django.core.exceptions import ObjectDoesNotExist +from django_countries import CountryField from track import contexts from track.views import server_track from eventtracking import tracker @@ -213,6 +214,8 @@ class UserProfile(models.Model): choices=LEVEL_OF_EDUCATION_CHOICES ) 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) allow_certificate = models.BooleanField(default=1) @@ -286,6 +289,68 @@ EVENT_NAME_ENROLLMENT_ACTIVATED = 'edx.course.enrollment.activated' 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): """ Represents a Student's Enrollment record for a single Course. You should diff --git a/common/djangoapps/student/roles.py b/common/djangoapps/student/roles.py index 7020bcd5d3..a0783196b5 100644 --- a/common/djangoapps/student/roles.py +++ b/common/djangoapps/student/roles.py @@ -157,33 +157,32 @@ class CourseRole(GroupBasedRole): # direct copy from auth.authz.get_all_course_role_groupnames will refactor to one impl asap groupnames = [] - # pylint: disable=no-member if isinstance(self.location, Location): 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 except InvalidLocationError: # will occur on old locations where location is not of category course if course_context is None: raise CourseContextRequired() else: - groupnames.append('{0}_{1}'.format(role, course_context)) + groupnames.append(u'{0}_{1}'.format(role, course_context)) try: 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): # if it's never been mapped, the auth won't be via the Locator syntax pass # 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): - 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 old_location = loc_mapper().translate_locator_to_location(self.location, get_course=True) if old_location: # 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. - 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) @@ -193,15 +192,14 @@ class OrgRole(GroupBasedRole): A named role in a particular org """ def __init__(self, role, location): - # pylint: disable=no-member - location = Location(location) - super(OrgRole, self).__init__(['{}_{}'.format(role, location.org)]) + super(OrgRole, self).__init__([u'{}_{}'.format(role, location.org)]) class CourseStaffRole(CourseRole): """A Staff member of a course""" ROLE = 'staff' + def __init__(self, *args, **kwargs): super(CourseStaffRole, self).__init__(self.ROLE, *args, **kwargs) diff --git a/common/djangoapps/student/tests/email/test.txt b/common/djangoapps/student/tests/email/test.txt new file mode 100644 index 0000000000..11115a5a72 --- /dev/null +++ b/common/djangoapps/student/tests/email/test.txt @@ -0,0 +1 @@ +Test body. diff --git a/common/djangoapps/student/tests/email/test_subject.txt b/common/djangoapps/student/tests/email/test_subject.txt new file mode 100644 index 0000000000..6f4d1f63dc --- /dev/null +++ b/common/djangoapps/student/tests/email/test_subject.txt @@ -0,0 +1 @@ +Test subject. diff --git a/common/djangoapps/student/tests/emails/test_body.txt b/common/djangoapps/student/tests/emails/test_body.txt new file mode 100644 index 0000000000..11115a5a72 --- /dev/null +++ b/common/djangoapps/student/tests/emails/test_body.txt @@ -0,0 +1 @@ +Test body. diff --git a/common/djangoapps/student/tests/emails/test_subject.txt b/common/djangoapps/student/tests/emails/test_subject.txt new file mode 100644 index 0000000000..6f4d1f63dc --- /dev/null +++ b/common/djangoapps/student/tests/emails/test_subject.txt @@ -0,0 +1 @@ +Test subject. diff --git a/common/djangoapps/student/tests/test_login.py b/common/djangoapps/student/tests/test_login.py index 6593412cf4..9be1e7de04 100644 --- a/common/djangoapps/student/tests/test_login.py +++ b/common/djangoapps/student/tests/test_login.py @@ -255,7 +255,7 @@ class ExternalAuthShibTest(ModuleStoreTestCase): noshib_response = self.client.get(TARGET_URL, follow=True) self.assertEqual(noshib_response.redirect_chain[-1], ('http://testserver/accounts/login?next={url}'.format(url=TARGET_URL), 302)) - self.assertContains(noshib_response, ("Log into your {platform_name} Account" + self.assertContains(noshib_response, ("Log into your {platform_name} Account | {platform_name}" .format(platform_name=settings.PLATFORM_NAME))) self.assertEqual(noshib_response.status_code, 200) diff --git a/common/djangoapps/student/tests/test_massemail.py b/common/djangoapps/student/tests/test_massemail.py new file mode 100644 index 0000000000..39311a528b --- /dev/null +++ b/common/djangoapps/student/tests/test_massemail.py @@ -0,0 +1,50 @@ +""" +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') diff --git a/common/djangoapps/student/tests/test_massemail_users.txt b/common/djangoapps/student/tests/test_massemail_users.txt new file mode 100644 index 0000000000..98afe6a240 --- /dev/null +++ b/common/djangoapps/student/tests/test_massemail_users.txt @@ -0,0 +1,2 @@ +Fred +Barney diff --git a/common/djangoapps/student/tests/test_password_policy.py b/common/djangoapps/student/tests/test_password_policy.py new file mode 100644 index 0000000000..eaf296f7c2 --- /dev/null +++ b/common/djangoapps/student/tests/test_password_policy.py @@ -0,0 +1,239 @@ +# -*- 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']) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 95ff269a3a..147c587746 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -10,6 +10,8 @@ import string # pylint: disable=W0402 import urllib import uuid import time +from collections import defaultdict +from pytz import UTC from django.conf import settings from django.contrib.auth import logout, authenticate, login @@ -41,11 +43,11 @@ from course_modes.models import CourseMode from student.models import ( Registration, UserProfile, PendingNameChange, PendingEmailChange, CourseEnrollment, unique_id_for_user, - CourseEnrollmentAllowed, UserStanding, + CourseEnrollmentAllowed, UserStanding, LoginFailures ) from student.forms import PasswordResetFormNoActive -from verify_student.models import SoftwareSecurePhotoVerification +from verify_student.models import SoftwareSecurePhotoVerification, MidcourseReverificationWindow from certificates.models import CertificateStatuses, certificate_status_for_student from xmodule.course_module import CourseDescriptor @@ -73,10 +75,16 @@ from util.json_request import JsonResponse from microsite_configuration.middleware import MicrositeConfiguration +from util.password_policy_validators import ( + validate_password_length, validate_password_complexity, + validate_password_dictionary +) + log = logging.getLogger("edx.student") AUDIT_LOG = logging.getLogger("audit") Article = namedtuple('Article', 'title url author image deck publication publish_date') +ReverifyInfo = namedtuple('ReverifyInfo', 'course_id course_name course_number date status display') # pylint: disable=C0103 def csrf_token(context): @@ -176,6 +184,88 @@ def cert_info(user, course): return _cert_info(user, course, certificate_status_for_student(user, course.id)) +def reverification_info(course_enrollment_pairs, user, statuses): + """ + Returns reverification-related information for *all* of user's enrollments whose + reverification status is in status_list + + Args: + course_enrollment_pairs (list): list of (course, enrollment) tuples + user (User): the user whose information we want + statuses (list): a list of reverification statuses we want information for + example: ["must_reverify", "denied"] + + Returns: + dictionary of lists: dictionary with one key per status, e.g. + dict["must_reverify"] = [] + dict["must_reverify"] = [some information] + """ + reverifications = defaultdict(list) + for (course, enrollment) in course_enrollment_pairs: + info = single_course_reverification_info(user, course, enrollment) + if info: + reverifications[info.status].append(info) + + # Sort the data by the reverification_end_date + for status in statuses: + if reverifications[status]: + reverifications[status].sort(key=lambda x: x.date) + return reverifications + + +def single_course_reverification_info(user, course, enrollment): # pylint: disable=invalid-name + """Returns midcourse reverification-related information for user with enrollment in course. + + If a course has an open re-verification window, and that user has a verified enrollment in + the course, we return a tuple with relevant information. Returns None if there is no info.. + + Args: + user (User): the user we want to get information for + course (Course): the course in which the student is enrolled + enrollment (CourseEnrollment): the object representing the type of enrollment user has in course + + Returns: + ReverifyInfo: (course_id, course_name, course_number, date, status) + OR, None: None if there is no re-verification info for this enrollment + """ + window = MidcourseReverificationWindow.get_window(course.id, datetime.datetime.now(UTC)) + + # If there's no window OR the user is not verified, we don't get reverification info + if (not window) or (enrollment.mode != "verified"): + return None + return ReverifyInfo( + course.id, course.display_name, course.number, + window.end_date.strftime('%B %d, %Y %X %p'), + SoftwareSecurePhotoVerification.user_status(user, window)[0], + SoftwareSecurePhotoVerification.display_status(user, window), + ) + + +def get_course_enrollment_pairs(user, course_org_filter, org_filter_out_set): + """ + Get the relevant set of (Course, CourseEnrollment) pairs to be displayed on + a student's dashboard. + """ + for enrollment in CourseEnrollment.enrollments_for_user(user): + try: + course = course_from_id(enrollment.course_id) + + # if we are in a Microsite, then filter out anything that is not + # attributed (by ORG) to that Microsite + if course_org_filter and course_org_filter != course.location.org: + continue + # Conversely, if we are not in a Microsite, then let's filter out any enrollments + # with courses attributed (by ORG) to Microsites + elif course.location.org in org_filter_out_set: + continue + + yield (course, enrollment) + except ItemNotFoundError: + log.error("User {0} enrolled in non-existent course {1}" + .format(user.username, enrollment.course_id)) + + + def _cert_info(user, course, cert_status): """ Implements the logic for cert_info -- split out for testing. @@ -316,11 +406,6 @@ def complete_course_mode_info(course_id, enrollment): def dashboard(request): user = request.user - # Build our (course, enrollment) list for the user, but ignore any courses that no - # longer exist (because the course IDs have changed). Still, we don't delete those - # enrollments, because it could have been a data push snafu. - course_enrollment_pairs = [] - # for microsites, we want to filter and only show enrollments for courses within # the microsites 'ORG' course_org_filter = MicrositeConfiguration.get_microsite_configuration_value('course_org_filter') @@ -333,23 +418,10 @@ def dashboard(request): if course_org_filter: org_filter_out_set.remove(course_org_filter) - for enrollment in CourseEnrollment.enrollments_for_user(user): - try: - course = course_from_id(enrollment.course_id) - - # if we are in a Microsite, then filter out anything that is not - # attributed (by ORG) to that Microsite - if course_org_filter and course_org_filter != course.location.org: - continue - # Conversely, if we are not in a Microsite, then let's filter out any enrollments - # with courses attributed (by ORG) to Microsites - elif course.location.org in org_filter_out_set: - continue - - course_enrollment_pairs.append((course, enrollment)) - except ItemNotFoundError: - log.error("User {0} enrolled in non-existent course {1}" - .format(user.username, enrollment.course_id)) + # Build our (course, enrollment) list for the user, but ignore any courses that no + # longer exist (because the course IDs have changed). Still, we don't delete those + # enrollments, because it could have been a data push snafu. + course_enrollment_pairs = list(get_course_enrollment_pairs(user, course_org_filter, org_filter_out_set)) course_optouts = Optout.objects.filter(user=user).values_list('course_id', flat=True) @@ -381,8 +453,13 @@ def dashboard(request): ) # Verification Attempts + # Used to generate the "you must reverify for course x" banner verification_status, verification_msg = SoftwareSecurePhotoVerification.user_status(user) + # Gets data for midcourse reverifications, if any are necessary or have failed + statuses = ["approved", "denied", "pending", "must_reverify"] + reverifications = reverification_info(course_enrollment_pairs, user, statuses) + show_refund_option_for = frozenset(course.id for course, _enrollment in course_enrollment_pairs if _enrollment.refundable()) @@ -393,6 +470,10 @@ def dashboard(request): except ExternalAuthMap.DoesNotExist: pass + # If there are *any* denied reverifications that have not been toggled off, + # we'll display the banner + denied_banner = any(item.display for item in reverifications["denied"]) + context = {'course_enrollment_pairs': course_enrollment_pairs, 'course_optouts': course_optouts, 'message': message, @@ -403,9 +484,12 @@ def dashboard(request): 'all_course_modes': course_modes, 'cert_statuses': cert_statuses, 'show_email_settings_for': show_email_settings_for, + 'reverifications': reverifications, 'verification_status': verification_status, 'verification_msg': verification_msg, 'show_refund_option_for': show_refund_option_for, + 'denied_banner': denied_banner, + 'billing_email': settings.PAYMENT_SUPPORT_EMAIL, } return render_to_response('dashboard.html', context) @@ -490,9 +574,9 @@ def change_enrollment(request): org, course_num, run = course_id.split("/") dog_stats_api.increment( "common.student.enrollment", - tags=["org:{0}".format(org), - "course:{0}".format(course_num), - "run:{0}".format(run)] + tags=[u"org:{0}".format(org), + u"course:{0}".format(course_num), + u"run:{0}".format(run)] ) CourseEnrollment.enroll(user, course.id, mode=current_mode.slug) @@ -607,6 +691,17 @@ def login_user(request, error=""): # This is actually the common case, logging in user without external linked login AUDIT_LOG.info("User %s w/o external auth attempting login", user) + # see if account has been locked out due to excessive login failres + user_found_by_email_lookup = user + if user_found_by_email_lookup and LoginFailures.is_feature_enabled(): + if LoginFailures.is_user_locked_out(user_found_by_email_lookup): + return HttpResponse( + json.dumps({ + 'success': False, + 'value': _('This account has been temporarily locked due to excessive login failures. Try again later.') + }) + ) + # if the user doesn't exist, we want to set the username to an invalid # username so that authentication is guaranteed to fail and we can take # advantage of the ratelimited backend @@ -618,6 +713,10 @@ def login_user(request, error=""): return HttpResponse(json.dumps({'success': False, 'value': _('Too many failed login attempts. Try again later.')})) if user is None: + # tick the failed login counters if the user exists in the database + if user_found_by_email_lookup and LoginFailures.is_feature_enabled(): + LoginFailures.increment_lockout_counter(user_found_by_email_lookup) + # if we didn't find this username earlier, the account for this email # doesn't exist, and doesn't have a corresponding password if username != "": @@ -625,6 +724,10 @@ def login_user(request, error=""): return HttpResponse(json.dumps({'success': False, 'value': _('Email or password is incorrect.')})) + # successful login, clear failed login attempts counters, if applicable + if LoginFailures.is_feature_enabled(): + LoginFailures.clear_lockout_counter(user) + if user is not None and user.is_active: try: # We do not log here, because we have a handler registered @@ -825,6 +928,8 @@ def _do_create_account(post_vars): profile.level_of_education = post_vars.get('level_of_education') profile.gender = post_vars.get('gender') profile.mailing_address = post_vars.get('mailing_address') + profile.city = post_vars.get('city') + profile.country = post_vars.get('country') profile.goals = post_vars.get('goals') try: @@ -849,6 +954,7 @@ def create_account(request, post_override=None): js = {'success': False} post_vars = post_override if post_override else request.POST + extra_fields = getattr(settings, 'REGISTRATION_EXTRA_FIELDS', {}) # if doing signup for an external authorization, then get email, password, name from the eamap # don't use the ones from the form, since the user could have hacked those @@ -875,24 +981,29 @@ def create_account(request, post_override=None): if a not in post_vars: js['value'] = _("Error (401 {field}). E-mail us.").format(field=a) js['field'] = a - return HttpResponse(json.dumps(js)) + return JsonResponse(js, status=400) - if post_vars.get('honor_code', 'false') != u'true': + if extra_fields.get('honor_code', 'required') == 'required' and \ + post_vars.get('honor_code', 'false') != u'true': js['value'] = _("To enroll, you must follow the honor code.").format(field=a) js['field'] = 'honor_code' - return HttpResponse(json.dumps(js)) + return JsonResponse(js, status=400) # Can't have terms of service for certain SHIB users, like at Stanford - tos_not_required = (settings.FEATURES.get("AUTH_USE_SHIB") and - settings.FEATURES.get('SHIB_DISABLE_TOS') and - DoExternalAuth and - eamap.external_domain.startswith(external_auth.views.SHIBBOLETH_DOMAIN_PREFIX)) + tos_required = ( + not settings.FEATURES.get("AUTH_USE_SHIB") or + not settings.FEATURES.get("SHIB_DISABLE_TOS") or + not DoExternalAuth or + not eamap.external_domain.startswith( + external_auth.views.SHIBBOLETH_DOMAIN_PREFIX + ) + ) - if not tos_not_required: + if tos_required: if post_vars.get('terms_of_service', 'false') != u'true': js['value'] = _("You must accept the terms of service.").format(field=a) js['field'] = 'terms_of_service' - return HttpResponse(json.dumps(js)) + return JsonResponse(js, status=400) # Confirm appropriate fields are there. # TODO: Check e-mail format is correct. @@ -900,35 +1011,64 @@ def create_account(request, post_override=None): # this is a good idea # TODO: Check password is sane - required_post_vars = ['username', 'email', 'name', 'password', 'terms_of_service', 'honor_code'] - if tos_not_required: - required_post_vars = ['username', 'email', 'name', 'password', 'honor_code'] + required_post_vars = ['username', 'email', 'name', 'password'] + required_post_vars += [fieldname for fieldname, val in extra_fields.items() + if val == 'required'] + if tos_required: + required_post_vars.append('terms_of_service') - for a in required_post_vars: - if len(post_vars[a]) < 2: - error_str = {'username': 'Username must be minimum of two characters long.', - 'email': 'A properly formatted e-mail is required.', - 'name': 'Your legal name must be a minimum of two characters long.', - 'password': 'A valid password is required.', - 'terms_of_service': 'Accepting Terms of Service is required.', - 'honor_code': 'Agreeing to the Honor Code is required.'} - js['value'] = error_str[a] - js['field'] = a - return HttpResponse(json.dumps(js)) + for field_name in required_post_vars: + if field_name in ('gender', 'level_of_education'): + min_length = 1 + else: + min_length = 2 + + if len(post_vars[field_name]) < min_length: + error_str = { + 'username': _('Username must be minimum of two characters long'), + 'email': _('A properly formatted e-mail is required'), + 'name': _('Your legal name must be a minimum of two characters long'), + 'password': _('A valid password is required'), + 'terms_of_service': _('Accepting Terms of Service is required'), + 'honor_code': _('Agreeing to the Honor Code is required'), + 'level_of_education': _('A level of education is required'), + 'gender': _('Your gender is required'), + 'year_of_birth': _('Your year of birth is required'), + 'mailing_address': _('Your mailing address is required'), + 'goals': _('A description of your goals is required'), + 'city': _('A city is required'), + 'country': _('A country is required') + } + js['value'] = error_str[field_name] + js['field'] = field_name + return JsonResponse(js, status=400) try: validate_email(post_vars['email']) except ValidationError: js['value'] = _("Valid e-mail is required.").format(field=a) js['field'] = 'email' - return HttpResponse(json.dumps(js)) + return JsonResponse(js, status=400) try: validate_slug(post_vars['username']) except ValidationError: js['value'] = _("Username should only consist of A-Z and 0-9, with no spaces.").format(field=a) js['field'] = 'username' - return HttpResponse(json.dumps(js)) + return JsonResponse(js, status=400) + + # enforce password complexity as an optional feature + if settings.FEATURES.get('ENFORCE_PASSWORD_POLICY', False): + try: + password = post_vars['password'] + + validate_password_length(password) + validate_password_complexity(password) + validate_password_dictionary(password) + except ValidationError, err: + js['value'] = _('Password: ') + '; '.join(err.messages) + js['field'] = 'password' + return JsonResponse(js, status=400) # Ok, looks like everything is legit. Create the account. ret = _do_create_account(post_vars) @@ -964,7 +1104,10 @@ def create_account(request, post_override=None): except: log.warning('Unable to send activation email to user', exc_info=True) js['value'] = _('Could not send activation e-mail.') - return HttpResponse(json.dumps(js)) + # What is the correct status code to use here? I think it's 500, because + # the problem is on the server's end -- but also, the account was created. + # Seems like the core part of the request was successful. + return JsonResponse(js, status=500) # Immediately after a user creates an account, we log them in. They are only # logged in until they close the browser. They can't log in again until they click @@ -991,14 +1134,12 @@ def create_account(request, post_override=None): login_user.save() AUDIT_LOG.info(u"Login activated on extauth account - {0} ({1})".format(login_user.username, login_user.email)) - redirect_url = try_change_enrollment(request) - dog_stats_api.increment("common.student.account_created") - response_params = {'success': True, - 'redirect_url': redirect_url} - - response = HttpResponse(json.dumps(response_params)) + response = JsonResponse({ + 'success': True, + 'redirect_url': try_change_enrollment(request), + }) # set the login cookie for the edx marketing site # we want this cookie to be accessed via javascript diff --git a/common/djangoapps/terrain/start_stubs.py b/common/djangoapps/terrain/start_stubs.py index aec88a1cc1..7d115d04a7 100644 --- a/common/djangoapps/terrain/start_stubs.py +++ b/common/djangoapps/terrain/start_stubs.py @@ -8,8 +8,6 @@ from terrain.stubs.youtube import StubYouTubeService from terrain.stubs.xqueue import StubXQueueService -USAGE = "USAGE: python -m fakes.start SERVICE_NAME PORT_NUM" - SERVICES = { "youtube": {"port": settings.YOUTUBE_PORT, "class": StubYouTubeService}, "xqueue": {"port": settings.XQUEUE_PORT, "class": StubXQueueService}, diff --git a/common/djangoapps/terrain/steps.py b/common/djangoapps/terrain/steps.py index dea0e6f0f3..da9b88de57 100644 --- a/common/djangoapps/terrain/steps.py +++ b/common/djangoapps/terrain/steps.py @@ -57,7 +57,7 @@ def i_visit_the_dashboard(step): @step('I should be on the dashboard page$') def i_should_be_on_the_dashboard(step): 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$') diff --git a/common/djangoapps/terrain/stubs/http.py b/common/djangoapps/terrain/stubs/http.py index 14dcf8ac2f..0a202c413a 100644 --- a/common/djangoapps/terrain/stubs/http.py +++ b/common/djangoapps/terrain/stubs/http.py @@ -23,14 +23,13 @@ class StubHttpRequestHandler(BaseHTTPRequestHandler, object): """ Redirect messages to keep the test console clean. """ + LOGGER.debug(self._format_msg(format_str, *args)) - msg = "{0} - - [{1}] {2}\n".format( - self.client_address[0], - self.log_date_time_string(), - format_str % args - ) - - LOGGER.debug(msg) + def log_error(self, format_str, *args): + """ + Helper to log a server error. + """ + LOGGER.error(self._format_msg(format_str, *args)) @lazy def request_content(self): @@ -76,22 +75,39 @@ class StubHttpRequestHandler(BaseHTTPRequestHandler, object): def do_PUT(self): """ 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/": - for key, value in self.post_dict.iteritems(): - self.log_message("Set config '{0}' to '{1}'".format(key, value)) + if len(self.post_dict) > 0: + for key, value in self.post_dict.iteritems(): - try: - value = json.loads(value) + # 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") - except ValueError: - self.log_message(u"Could not parse JSON: {0}".format(value)) - self.send_response(400) + self.log_message(u"Set config '{0}' to '{1}'".format(key, value)) - else: - self.server.set_config(unicode(key, 'utf-8'), value) - self.send_response(200) + try: + value = json.loads(value) + + except ValueError: + self.log_message(u"Could not parse JSON: {0}".format(value)) + self.send_response(400) + + else: + self.server.config[key] = value + self.send_response(200) + + # No parameters sent to configure, so return success by default + else: + self.send_response(200) else: self.send_response(404) @@ -119,6 +135,18 @@ class StubHttpRequestHandler(BaseHTTPRequestHandler, object): if content is not None: 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): """ @@ -138,7 +166,7 @@ class StubHttpService(HTTPServer, object): HTTPServer.__init__(self, address, self.HANDLER_CLASS) # Create a dict to store configuration values set by the client - self._config = dict() + self.config = dict() # Start the server in a separate thread server_thread = threading.Thread(target=self.serve_forever) @@ -165,17 +193,3 @@ class StubHttpService(HTTPServer, object): """ _, port = self.server_address 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 - diff --git a/common/djangoapps/terrain/stubs/start.py b/common/djangoapps/terrain/stubs/start.py new file mode 100644 index 0000000000..1533607583 --- /dev/null +++ b/common/djangoapps/terrain/stubs/start.py @@ -0,0 +1,72 @@ +""" +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() diff --git a/common/djangoapps/terrain/stubs/tests/test_http.py b/common/djangoapps/terrain/stubs/tests/test_http.py index fb09bad173..ffad4cd88f 100644 --- a/common/djangoapps/terrain/stubs/tests/test_http.py +++ b/common/djangoapps/terrain/stubs/tests/test_http.py @@ -13,6 +13,7 @@ class StubHttpServiceTest(unittest.TestCase): def setUp(self): self.server = StubHttpService() self.addCleanup(self.server.shutdown) + self.url = "http://127.0.0.1:{0}/set_config".format(self.server.port) def test_configure(self): """ @@ -21,33 +22,38 @@ class StubHttpServiceTest(unittest.TestCase): """ params = { 'test_str': 'This is only a test', + 'test_empty': '', 'test_int': 12345, 'test_float': 123.45, + 'test_dict': { 'test_key': 'test_val' }, + 'test_empty_dict': {}, 'test_unicode': u'\u2603 the snowman', - 'test_dict': { 'test_key': 'test_val' } + 'test_none': None, + 'test_boolean': False } 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) # Check that the expected values were set in the configuration for key, val in params.iteritems(): - self.assertEqual(self.server.config(key), val) - - def test_default_config(self): - self.assertEqual(self.server.config('not_set', default=42), 42) + self.assertEqual(self.server.config.get(key), val) def test_bad_json(self): - response = requests.put( - "http://127.0.0.1:{0}/set_config".format(self.server.port), - data="{,}" - ) + response = requests.put(self.url, data="{,}") + self.assertEqual(response.status_code, 400) + + 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) def test_unknown_path(self): diff --git a/common/djangoapps/terrain/stubs/tests/test_xqueue_stub.py b/common/djangoapps/terrain/stubs/tests/test_xqueue_stub.py index 222792ebb3..5ac170b187 100644 --- a/common/djangoapps/terrain/stubs/tests/test_xqueue_stub.py +++ b/common/djangoapps/terrain/stubs/tests/test_xqueue_stub.py @@ -5,70 +5,172 @@ Unit tests for stub XQueue implementation. import mock import unittest import json -import urllib +import requests import time -from terrain.stubs.xqueue import StubXQueueService +import copy +from terrain.stubs.xqueue import StubXQueueService, StubXQueueHandler class StubXQueueServiceTest(unittest.TestCase): def setUp(self): 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) # 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): - # Send a grade request + # Post a submission to the stub XQueue 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({ - 'lms_callback_url': callback_url, - 'lms_key': 'test_queuekey', - 'queue_name': 'test_queue' - }) + # Check the response we receive + # (Should be the default grading response) + expected_body = json.dumps({'correct': True, 'score': 1, 'msg': '
'}) + self._check_grade_response(post, callback_url, expected_header, expected_body) - grade_body = json.dumps({ - 'student_info': 'test', - 'grader_payload': 'test', - 'student_response': 'test' - }) + @mock.patch('terrain.stubs.xqueue.post') + def test_configure_default_response(self, post): + # Configure the default response for submissions to any queue + response_content = {'test_response': 'test_content'} + self.server.config['default'] = response_content + + # Post a submission to the stub XQueue + 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' + }) + ) + + # Check the response we receive + # (Should be the default grading response) + self._check_grade_response( + post, callback_url, expected_header, json.dumps(response_content) + ) + + @mock.patch('terrain.stubs.xqueue.post') + 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 + + # 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': 'This is only a test.'}) + ) + + # Check that we receive the response we configured + 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': grade_header, - 'xqueue_body': grade_body - } - - response_handle = urllib.urlopen( - self.url + '/xqueue/submit', - urllib.urlencode(grade_request) - ) - - response_dict = json.loads(response_handle.read()) - - # Expect that the response is success - self.assertEqual(response_dict['return_code'], 0) - - # Expect that the server tries to post back the grading info - xqueue_body = json.dumps( - {'correct': True, 'score': 1, 'msg': '
'} - ) - - expected_callback_dict = { - 'xqueue_header': grade_header, + 'xqueue_header': json.dumps({ + 'lms_callback_url': callback_url, + 'lms_key': 'test_queuekey', + 'queue_name': 'test_queue' + }), '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 - # Time out if it takes too long - start_time = time.time() - while time.time() - start_time < 5: - if post.called: - break + # If it takes too long, continue anyway + self._wait_for_mock_called(post_mock, max_time=10) + + # Check the response posted back to us + # 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 - 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) diff --git a/common/djangoapps/terrain/stubs/tests/test_youtube_stub.py b/common/djangoapps/terrain/stubs/tests/test_youtube_stub.py index 3c8ef46908..0e5f27aca8 100644 --- a/common/djangoapps/terrain/stubs/tests/test_youtube_stub.py +++ b/common/djangoapps/terrain/stubs/tests/test_youtube_stub.py @@ -12,7 +12,7 @@ class StubYouTubeServiceTest(unittest.TestCase): def setUp(self): self.server = StubYouTubeService() self.url = "http://127.0.0.1:{0}/".format(self.server.port) - self.server.set_config('time_to_response', 0.0) + self.server.config['time_to_response'] = 0.0 self.addCleanup(self.server.shutdown) def test_unused_url(self): diff --git a/common/djangoapps/terrain/stubs/xqueue.py b/common/djangoapps/terrain/stubs/xqueue.py index 96e8e78f7b..52ca14ae34 100644 --- a/common/djangoapps/terrain/stubs/xqueue.py +++ b/common/djangoapps/terrain/stubs/xqueue.py @@ -1,10 +1,17 @@ """ Stub implementation of XQueue for acceptance tests. + +Configuration values: + "default" (dict): Default response to be sent to LMS as a grade for a submission + "" (dict): Grade response to return for submissions containing the text + +If no grade response is configured, a default response will be returned. """ from .http import StubHttpRequestHandler, StubHttpService import json -import requests +import copy +from requests import post import threading @@ -34,15 +41,13 @@ class StubXQueueHandler(StubHttpRequestHandler): except KeyError: # If the message doesn't have a header or body, - # then it's malformed. - # Respond with failure + # then it's malformed. Respond with failure error_msg = "XQueue received invalid grade request" self._send_immediate_response(False, message=error_msg) except ValueError: # If we could not decode the body or header, # respond with failure - error_msg = "XQueue could not decode grade request" self._send_immediate_response(False, message=error_msg) @@ -56,19 +61,18 @@ class StubXQueueHandler(StubHttpRequestHandler): # Otherwise, the problem will not realize it's # queued and it will keep waiting for a response indefinitely delayed_grade_func = lambda: self._send_grade_response( - callback_url, xqueue_header + callback_url, xqueue_header, self.post_dict['xqueue_body'] ) threading.Timer( - self.server.config('response_delay', default=self.DEFAULT_RESPONSE_DELAY), + self.server.config.get('response_delay', self.DEFAULT_RESPONSE_DELAY), delayed_grade_func ).start() # If we get a request that's not to the grading submission # URL, return an error else: - error_message = "Invalid request URL" - self._send_immediate_response(False, message=error_message) + self._send_immediate_response(False, message="Invalid request URL") def _send_immediate_response(self, success, message=""): """ @@ -90,13 +94,49 @@ class StubXQueueHandler(StubHttpRequestHandler): else: self.send_response(500) - def _send_grade_response(self, postback_url, xqueue_header): + def _send_grade_response(self, postback_url, xqueue_header, xqueue_body_json): """ POST the grade response back to the client - using the response provided by the server configuration + using the response provided by the server configuration. + + Uses the server configuration to determine what response to send: + 1) Specific response for submissions containing matching text in `xqueue_body` + 2) Default submission configured by client + 3) Default submission + + `postback_url` is the URL the client told us to post back to + `xqueue_header` (dict) is the full header the client sent us, which we will send back + to the client so it can authenticate us. + `xqueue_body_json` (json-encoded string) is the body of the submission the client sent us. """ - # Get the grade response from the server configuration - grade_response = self.server.config('grade_response', default=self.DEFAULT_GRADE_RESPONSE) + # First check if we have a configured response that matches the submission body + grade_response = None + + # This matches the pattern against the JSON-encoded xqueue_body + # This is very simplistic, but sufficient to associate a student response + # with a grading response. + # There is a danger here that a submission will match multiple response patterns. + # Rather than fail silently (which could cause unpredictable behavior in tests) + # we abort and log a debugging message. + for pattern, response in self.server.config.iteritems(): + + if pattern in xqueue_body_json: + if grade_response is None: + grade_response = response + + # Multiple matches, so abort and log an error + else: + self.log_error( + "Multiple response patterns matched '{0}'".format(xqueue_body_json), + ) + return + + # Fall back to the default grade response configured for this queue, + # then to the default response. + if grade_response is None: + grade_response = self.server.config.get( + 'default', copy.deepcopy(self.DEFAULT_GRADE_RESPONSE) + ) # Wrap the message in
tags to ensure that it is valid XML if isinstance(grade_response, dict) and 'msg' in grade_response: @@ -107,8 +147,8 @@ class StubXQueueHandler(StubHttpRequestHandler): 'xqueue_body': json.dumps(grade_response) } - requests.post(postback_url, data=data) - self.log_message("XQueue: sent grading response {0}".format(data)) + post(postback_url, data=data) + self.log_message("XQueue: sent grading response {0} to {1}".format(data, postback_url)) def _is_grade_request(self): return 'xqueue/submit' in self.path diff --git a/common/djangoapps/terrain/stubs/youtube.py b/common/djangoapps/terrain/stubs/youtube.py index fc68cc3da0..1facc16e83 100644 --- a/common/djangoapps/terrain/stubs/youtube.py +++ b/common/djangoapps/terrain/stubs/youtube.py @@ -72,7 +72,7 @@ class StubYouTubeHandler(StubHttpRequestHandler): Requires sending back callback id. """ # Delay the response to simulate network latency - time.sleep(self.server.config('time_to_response', self.DEFAULT_DELAY_SEC)) + time.sleep(self.server.config.get('time_to_response', self.DEFAULT_DELAY_SEC)) # Construct the response content callback = self.get_params['callback'][0] diff --git a/common/djangoapps/util/password_policy_validators.py b/common/djangoapps/util/password_policy_validators.py new file mode 100644 index 0000000000..987ec30a02 --- /dev/null +++ b/common/djangoapps/util/password_policy_validators.py @@ -0,0 +1,92 @@ +# pylint: disable=E1101 +""" +This file exposes a number of password complexity validators which can be optionally added to +account creation + +This file was inspired by the django-passwords project at https://github.com/dstufft/django-passwords +authored by dstufft (https://github.com/dstufft) +""" +from __future__ import division +import string # pylint: disable=W0402 + +from django.core.exceptions import ValidationError +from django.utils.translation import ugettext_lazy as _ +from django.conf import settings + +import nltk + + +def validate_password_length(value): + """ + Validator that enforces minimum length of a password + """ + message = _("Invalid Length ({0})") + code = "length" + + min_length = getattr(settings, 'PASSWORD_MIN_LENGTH', None) + max_length = getattr(settings, 'PASSWORD_MAX_LENGTH', None) + + if min_length and len(value) < min_length: + raise ValidationError(message.format(_("must be {0} characters or more").format(min_length)), code=code) + elif max_length and len(value) > max_length: + raise ValidationError(message.format(_("must be {0} characters or less").format(max_length)), code=code) + + +def validate_password_complexity(value): + """ + Validator that enforces minimum complexity + """ + message = _("Must be more complex ({0})") + code = "complexity" + + complexities = getattr(settings, "PASSWORD_COMPLEXITY", None) + + if complexities is None: + return + + uppercase, lowercase, digits, non_ascii, punctuation = set(), set(), set(), set(), set() + + for character in value: + if character.isupper(): + uppercase.add(character) + elif character.islower(): + lowercase.add(character) + elif character.isdigit(): + digits.add(character) + elif character in string.punctuation: + punctuation.add(character) + else: + non_ascii.add(character) + + words = set(value.split()) + + errors = [] + if len(uppercase) < complexities.get("UPPER", 0): + errors.append(_("must contain {0} or more uppercase characters").format(complexities["UPPER"])) + if len(lowercase) < complexities.get("LOWER", 0): + errors.append(_("must contain {0} or more lowercase characters").format(complexities["LOWER"])) + if len(digits) < complexities.get("DIGITS", 0): + errors.append(_("must contain {0} or more digits").format(complexities["DIGITS"])) + if len(punctuation) < complexities.get("PUNCTUATION", 0): + errors.append(_("must contain {0} or more punctuation characters").format(complexities["PUNCTUATION"])) + if len(non_ascii) < complexities.get("NON ASCII", 0): + errors.append(_("must contain {0} or more non ascii characters").format(complexities["NON ASCII"])) + if len(words) < complexities.get("WORDS", 0): + errors.append(_("must contain {0} or more unique words").format(complexities["WORDS"])) + + if errors: + raise ValidationError(message.format(u', '.join(errors)), code=code) + + +def validate_password_dictionary(value): + """ + Insures that the password is not too similar to a defined set of dictionary words + """ + password_max_edit_distance = getattr(settings, "PASSWORD_DICTIONARY_EDIT_DISTANCE_THRESHOLD", None) + password_dictionary = getattr(settings, "PASSWORD_DICTIONARY", None) + + if password_max_edit_distance and password_dictionary: + for word in password_dictionary: + distance = nltk.metrics.distance.edit_distance(value, word) + if distance <= password_max_edit_distance: + raise ValidationError(_("Too similar to a restricted dictionary word."), code="dictionary_word") diff --git a/common/djangoapps/util/validate_on_save.py b/common/djangoapps/util/validate_on_save.py new file mode 100644 index 0000000000..ff78d460b9 --- /dev/null +++ b/common/djangoapps/util/validate_on_save.py @@ -0,0 +1,14 @@ +""" Utility mixin; forces models to validate *before* saving to db """ + + +class ValidateOnSaveMixin(object): + """ + Forces models to call their full_clean method prior to saving + """ + def save(self, force_insert=False, force_update=False, **kwargs): + """ + Modifies the save method to call full_clean + """ + if not (force_insert or force_update): + self.full_clean() + super(ValidateOnSaveMixin, self).save(force_insert, force_update, **kwargs) diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index f011b3813e..d14af5b0d8 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -372,7 +372,7 @@ class LoncapaProblem(object): # TODO: figure out where to get file submissions when rescoring. if 'filesubmission' in responder.allowed_inputfields and student_answers is None: _ = self.capa_system.i18n.ugettext - raise Exception(_("Cannot rescore problems with possible file submissions")) + raise Exception(_(u"Cannot rescore problems with possible file submissions")) # use 'student_answers' only if it is provided, and if it might contain a file # submission that would not exist in the persisted "student_answers". diff --git a/common/lib/capa/capa/correctmap.py b/common/lib/capa/capa/correctmap.py index 2ea4a35c02..df7efee343 100644 --- a/common/lib/capa/capa/correctmap.py +++ b/common/lib/capa/capa/correctmap.py @@ -50,7 +50,7 @@ class CorrectMap(object): ): if answer_id is not None: - self.cmap[str(answer_id)] = { + self.cmap[answer_id] = { 'correctness': correctness, 'npoints': npoints, 'msg': msg, diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index dc0fbfeede..4b06369599 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -949,8 +949,6 @@ class NumericalResponse(LoncapaResponse): if self.range_tolerance: if isinstance(student_float, complex): raise StudentInputError(_(u"You may not use complex numbers in range tolerance problems")) - if isnan(student_float): - raise general_exception boundaries = [] for inclusion, answer in zip(self.inclusion, self.answer_range): boundary = self.get_staff_ans(answer) diff --git a/common/lib/capa/capa/tests/test_responsetypes.py b/common/lib/capa/capa/tests/test_responsetypes.py index 6d2b57d65d..3a3a5c3451 100644 --- a/common/lib/capa/capa/tests/test_responsetypes.py +++ b/common/lib/capa/capa/tests/test_responsetypes.py @@ -649,7 +649,7 @@ class StringResponseTest(ResponseTest): def test_case_sensitive(self): # Test single answer problem_specified = self.build_problem(answer="Second", case_sensitive=True) - + # should also be case_sensitive if case sensitivity is not specified problem_not_specified = self.build_problem(answer="Second") problems = [problem_specified, problem_not_specified] @@ -1105,11 +1105,13 @@ class NumericalResponseTest(ResponseTest): with self.assertRaises(StudentInputError): problem.grade_answers(input_dict) - # test isnan variable + # test isnan student input: no exception, + # but problem should be graded as incorrect problem = self.build_problem(answer='(1, 5)') input_dict = {'1_2_1': ''} - with self.assertRaises(StudentInputError): - problem.grade_answers(input_dict) + correct_map = problem.grade_answers(input_dict) + correctness = correct_map.get_correctness('1_2_1') + self.assertEqual(correctness, 'incorrect') # test invalid range tolerance answer with self.assertRaises(StudentInputError): diff --git a/common/lib/capa/capa/xqueue_interface.py b/common/lib/capa/capa/xqueue_interface.py index 270b696f9f..78b9fcd008 100644 --- a/common/lib/capa/capa/xqueue_interface.py +++ b/common/lib/capa/capa/xqueue_interface.py @@ -62,7 +62,7 @@ class XQueueInterface(object): """ def __init__(self, url, django_auth, requests_auth=None): - self.url = url + self.url = unicode(url) self.auth = django_auth self.session = requests.Session() self.session.auth = requests_auth diff --git a/common/lib/xmodule/xmodule/capa_base.py b/common/lib/xmodule/xmodule/capa_base.py index f15f18219e..9f43632a74 100644 --- a/common/lib/xmodule/xmodule/capa_base.py +++ b/common/lib/xmodule/xmodule/capa_base.py @@ -328,6 +328,10 @@ class CapaMixin(CapaFields): if total > 0: if self.weight is not None: + # Progress objects expect total > 0 + if self.weight == 0: + return None + # scale score and total by weight/total: score = score * self.weight / total total = self.weight @@ -838,11 +842,14 @@ class CapaMixin(CapaFields): Publishes the student's current grade to the system as an event """ score = self.lcp.get_score() - self.runtime.publish({ - 'event_name': 'grade', - 'value': score['score'], - 'max_value': score['total'], - }) + self.runtime.publish( + self, + { + 'event_name': 'grade', + 'value': score['score'], + 'max_value': score['total'], + } + ) return {'grade': score['score'], 'max_grade': score['total']} diff --git a/common/lib/xmodule/xmodule/conditional_module.py b/common/lib/xmodule/xmodule/conditional_module.py index 9a2332d556..e296af5aa5 100644 --- a/common/lib/xmodule/xmodule/conditional_module.py +++ b/common/lib/xmodule/xmodule/conditional_module.py @@ -224,7 +224,7 @@ class ConditionalDescriptor(ConditionalFields, SequenceDescriptor): if child.tag == 'show': location = ConditionalDescriptor.parse_sources(child, system) children.extend(location) - show_tag_list.extend(location.url()) + show_tag_list.extend(location.url()) # pylint: disable=no-member else: try: descriptor = system.process_xml(etree.tostring(child)) diff --git a/common/lib/xmodule/xmodule/contentstore/content.py b/common/lib/xmodule/xmodule/contentstore/content.py index 61ff0bc192..50e7f5d848 100644 --- a/common/lib/xmodule/xmodule/contentstore/content.py +++ b/common/lib/xmodule/xmodule/contentstore/content.py @@ -34,7 +34,9 @@ class StaticContent(object): @staticmethod def generate_thumbnail_name(original_name): - return ('{0}' + XASSET_THUMBNAIL_TAIL_NAME).format(os.path.splitext(original_name)[0]) + return u"{name_root}{extension}".format( + name_root=os.path.splitext(original_name)[0], + extension=XASSET_THUMBNAIL_TAIL_NAME,) @staticmethod def compute_location(org, course, name, revision=None, is_thumbnail=False): @@ -64,7 +66,7 @@ class StaticContent(object): """ Returns a boolean if a path is believed to be a c4x link based on the leading element """ - return path_string.startswith('/{0}/'.format(XASSET_LOCATION_TAG)) + return path_string.startswith(u'/{0}/'.format(XASSET_LOCATION_TAG)) @staticmethod def renamespace_c4x_path(path_string, target_location): @@ -86,14 +88,14 @@ class StaticContent(object): the actual /c4x/... path which the client needs to reference static content """ if location is not None: - return "/static/{name}".format(**location.dict()) + return u"/static/{name}".format(**location.dict()) else: return None @staticmethod def get_base_url_path_for_course_assets(loc): if loc is not None: - return "/c4x/{org}/{course}/asset".format(**loc.dict()) + return u"/c4x/{org}/{course}/asset".format(**loc.dict()) @staticmethod def get_id_from_location(location): @@ -237,6 +239,6 @@ class ContentStore(object): except Exception, e: # log and continue as thumbnails are generally considered as optional - logging.exception("Failed to generate thumbnail for {0}. Exception: {1}".format(content.location, str(e))) + logging.exception(u"Failed to generate thumbnail for {0}. Exception: {1}".format(content.location, str(e))) return thumbnail_content, thumbnail_file_location diff --git a/common/lib/xmodule/xmodule/error_module.py b/common/lib/xmodule/xmodule/error_module.py index 4a513aad95..68735351fa 100644 --- a/common/lib/xmodule/xmodule/error_module.py +++ b/common/lib/xmodule/xmodule/error_module.py @@ -83,6 +83,11 @@ class ErrorDescriptor(ErrorFields, XModuleDescriptor): def _construct(cls, system, contents, error_msg, location): location = Location(location) + if error_msg is None: + # this string is not marked for translation because we don't have + # access to the user context, and this will only be seen by staff + error_msg = 'Error not available' + if location.category == 'error': location = location.replace( # Pick a unique url_name -- the sha1 hash of the contents. @@ -97,7 +102,6 @@ class ErrorDescriptor(ErrorFields, XModuleDescriptor): field_data = DictFieldData({ 'error_msg': str(error_msg), 'contents': contents, - 'display_name': 'Error: ' + location.url(), 'location': location, 'category': 'error' }) @@ -125,7 +129,7 @@ class ErrorDescriptor(ErrorFields, XModuleDescriptor): ) @classmethod - def from_descriptor(cls, descriptor, error_msg='Error not available'): + def from_descriptor(cls, descriptor, error_msg=None): return cls._construct( descriptor.runtime, str(descriptor), @@ -134,8 +138,8 @@ class ErrorDescriptor(ErrorFields, XModuleDescriptor): ) @classmethod - def from_xml(cls, xml_data, system, id_generator, - error_msg='Error not available'): + def from_xml(cls, xml_data, system, id_generator, # pylint: disable=arguments-differ + error_msg=None): '''Create an instance of this descriptor from the supplied data. Does not require that xml_data be parseable--just stores it and exports @@ -154,7 +158,7 @@ class ErrorDescriptor(ErrorFields, XModuleDescriptor): if error_node is not None: error_msg = error_node.text else: - error_msg = 'Error not available' + error_msg = None except etree.XMLSyntaxError: # Save the error to display later--overrides other problems diff --git a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee index 3ed8ba226d..7a3c366e22 100644 --- a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee @@ -368,6 +368,7 @@ class @CombinedOpenEnded @rub.initialize(@location) @child_state = 'assessing' @find_assessment_elements() + @answer_area.val(response.student_response) @rebind() answer_area_div = @$(@answer_area_div_sel) answer_area_div.html(response.student_response) diff --git a/common/lib/xmodule/xmodule/js/src/sequence/display.coffee b/common/lib/xmodule/xmodule/js/src/sequence/display.coffee index b78d3c62f4..8a9a6a2cd9 100644 --- a/common/lib/xmodule/xmodule/js/src/sequence/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/sequence/display.coffee @@ -5,6 +5,7 @@ class @Sequence @num_contents = @contents.length @id = @el.data('id') @ajaxUrl = @el.data('ajax-url') + @base_page_title = " | " + document.title @initProgress() @bind() @render parseInt(@el.data('position')) @@ -18,7 +19,10 @@ class @Sequence initProgress: -> @progressTable = {} # "#problem_#{id}" -> progress - + updatePageTitle: -> + # update the page title to include the current section + document.title = @link_for(@position).data('title') + @base_page_title + hookUpProgressEvent: -> $('.problems-wrapper').bind 'progressChanged', @updateProgress @@ -100,6 +104,7 @@ class @Sequence @position = new_position @toggleArrows() @hookUpProgressEvent() + @updatePageTitle() sequence_links = @$('#seq_content a.seqnav') sequence_links.click @goto diff --git a/common/lib/xmodule/xmodule/js/src/video/01_initialize.js b/common/lib/xmodule/xmodule/js/src/video/01_initialize.js index b088cd5a1c..928808c06b 100644 --- a/common/lib/xmodule/xmodule/js/src/video/01_initialize.js +++ b/common/lib/xmodule/xmodule/js/src/video/01_initialize.js @@ -382,8 +382,10 @@ function (VideoPlayer, CookieStorage) { isTouch = onTouchBasedDevice() || '', storage = CookieStorage('video_player'), speed = storage.getItem('video_speed_' + id) || + el.data('speed') || storage.getItem('general_speed') || - el.data('speed').toFixed(2).replace(/\.00$/, '.0') || '1.0'; + el.data('general-speed') || + '1.0'; if (isTouch) { el.addClass('is-touch'); @@ -397,7 +399,7 @@ function (VideoPlayer, CookieStorage) { id: id, isFullScreen: false, isTouch: isTouch, - speed: speed, + speed: Number(speed).toFixed(2).replace(/\.00$/, '.0'), storage: storage }); diff --git a/common/lib/xmodule/xmodule/lti_module.py b/common/lib/xmodule/xmodule/lti_module.py index 5257fd1247..f963c148a9 100644 --- a/common/lib/xmodule/xmodule/lti_module.py +++ b/common/lib/xmodule/xmodule/lti_module.py @@ -56,7 +56,6 @@ from pkg_resources import resource_string from xblock.core import String, Scope, List, XBlock from xblock.fields import Boolean, Float - log = logging.getLogger(__name__) @@ -328,6 +327,25 @@ class LTIModule(LTIFields, XModule): """ return u':'.join(urllib.quote(i) for i in (self.lti_id, self.get_resource_link_id(), self.get_user_id())) + def get_course(self): + """ + Return course by course id. + """ + course_location = CourseDescriptor.id_to_location(self.course_id) + course = self.descriptor.runtime.modulestore.get_item(course_location) + return course + + @property + def role(self): + """ + Get system user role and convert it to LTI role. + """ + roles = { + 'student': u'Student', + 'staff': u'Administrator', + 'instructor': u'Instructor', + } + return roles.get(self.system.get_user_role(), u'Student') def oauth_params(self, custom_parameters, client_key, client_secret): """ @@ -351,7 +369,7 @@ class LTIModule(LTIFields, XModule): u'launch_presentation_return_url': '', u'lti_message_type': u'basic-lti-launch-request', u'lti_version': 'LTI-1p0', - u'role': u'student', + u'roles': self.role, # Parameters required for grading: u'resource_link_id': self.get_resource_link_id(), @@ -510,7 +528,8 @@ oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D"'} if action == 'replaceResultRequest': self.system.publish( - event={ + self, + { 'event_name': 'grade', 'value': score * self.max_score(), 'max_value': self.max_score(), @@ -606,10 +625,7 @@ oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D"'} """ Obtains client_key and client_secret credentials from current course. """ - course_id = self.course_id - course_location = CourseDescriptor.id_to_location(course_id) - course = self.descriptor.runtime.modulestore.get_item(course_location) - + course = self.get_course() for lti_passport in course.lti_passports: try: lti_id, key, secret = [i.strip() for i in lti_passport.split(':')] diff --git a/common/lib/xmodule/xmodule/modulestore/__init__.py b/common/lib/xmodule/xmodule/modulestore/__init__.py index 0a1cf9ee10..5d241731a7 100644 --- a/common/lib/xmodule/xmodule/modulestore/__init__.py +++ b/common/lib/xmodule/xmodule/modulestore/__init__.py @@ -30,12 +30,12 @@ URL_RE = re.compile(""" # TODO (cpennington): We should decide whether we want to expand the # list of valid characters in a location -INVALID_CHARS = re.compile(r"[^\w.-]") +INVALID_CHARS = re.compile(r"[^\w.%-]", re.UNICODE) # Names are allowed to have colons. -INVALID_CHARS_NAME = re.compile(r"[^\w.:-]") +INVALID_CHARS_NAME = re.compile(r"[^\w.:%-]", re.UNICODE) # html ids can contain word chars and dashes -INVALID_HTML_CHARS = re.compile(r"[^\w-]") +INVALID_HTML_CHARS = re.compile(r"[^\w-]", re.UNICODE) _LocationBase = namedtuple('LocationBase', 'tag org course category name revision') @@ -186,14 +186,14 @@ class Location(_LocationBase): elif isinstance(location, basestring): match = URL_RE.match(location) if match is None: - log.debug("location %r doesn't match URL", location) + log.debug(u"location %r doesn't match URL", location) raise InvalidLocationError(location) groups = match.groupdict() check_dict(groups) return _LocationBase.__new__(_cls, **groups) elif isinstance(location, (list, tuple)): if len(location) not in (5, 6): - log.debug('location has wrong length') + log.debug(u'location has wrong length') raise InvalidLocationError(location) if len(location) == 5: @@ -216,9 +216,9 @@ class Location(_LocationBase): """ Return a string containing the URL for this location """ - url = "{0.tag}://{0.org}/{0.course}/{0.category}/{0.name}".format(self) + url = u"{0.tag}://{0.org}/{0.course}/{0.category}/{0.name}".format(self) if self.revision: - url += "@" + self.revision + url += u"@{rev}".format(rev=self.revision) # pylint: disable=E1101 return url def html_id(self): @@ -226,7 +226,7 @@ class Location(_LocationBase): Return a string with a version of the location that is safe for use in html id attributes """ - id_string = "-".join(str(v) for v in self.list() if v is not None) + id_string = u"-".join(v for v in self.list() if v is not None) return Location.clean_for_html(id_string) def dict(self): @@ -240,6 +240,9 @@ class Location(_LocationBase): return list(self) def __str__(self): + return str(self.url().encode("utf-8")) + + def __unicode__(self): return self.url() def __repr__(self): @@ -254,7 +257,7 @@ class Location(_LocationBase): Throws an InvalidLocationError is this location does not represent a course. """ if self.category != 'course': - raise InvalidLocationError('Cannot call course_id for {0} because it is not of category course'.format(self)) + raise InvalidLocationError(u'Cannot call course_id for {0} because it is not of category course'.format(self)) return "/".join([self.org, self.course, self.name]) @@ -268,7 +271,9 @@ class Location(_LocationBase): _check_location_part(value, INVALID_CHARS_NAME) else: _check_location_part(value, INVALID_CHARS) - return super(Location, self)._replace(**kwargs) + + # namedtuple is an old-style class, so don't use super + return _LocationBase._replace(self, **kwargs) def replace(self, **kwargs): ''' diff --git a/common/lib/xmodule/xmodule/modulestore/django.py b/common/lib/xmodule/xmodule/modulestore/django.py index 13e0a2878f..62ed8de035 100644 --- a/common/lib/xmodule/xmodule/modulestore/django.py +++ b/common/lib/xmodule/xmodule/modulestore/django.py @@ -152,6 +152,9 @@ def clear_existing_modulestores(): _MODULESTORES.clear() # pylint: disable=W0603 global _loc_singleton + cache = getattr(_loc_singleton, "cache", None) + if cache: + cache.clear() _loc_singleton = None diff --git a/common/lib/xmodule/xmodule/modulestore/loc_mapper_store.py b/common/lib/xmodule/xmodule/modulestore/loc_mapper_store.py index 9d5c29e6ae..37853d0789 100644 --- a/common/lib/xmodule/xmodule/modulestore/loc_mapper_store.py +++ b/common/lib/xmodule/xmodule/modulestore/loc_mapper_store.py @@ -88,12 +88,12 @@ class LocMapperStore(object): """ if package_id is None: if course_location.category == 'course': - package_id = "{0.org}.{0.course}.{0.name}".format(course_location) + package_id = u"{0.org}.{0.course}.{0.name}".format(course_location) else: - package_id = "{0.org}.{0.course}".format(course_location) + package_id = u"{0.org}.{0.course}".format(course_location) # very like _interpret_location_id but w/o the _id location_id = self._construct_location_son( - course_location.org, course_location.course, + course_location.org, course_location.course, course_location.name if course_location.category == 'course' else None ) @@ -185,7 +185,6 @@ class LocMapperStore(object): self._cache_location_map_entry(old_style_course_id, location, published_usage, draft_usage) return result - def translate_locator_to_location(self, locator, get_course=False): """ Returns an old style Location for the given Locator if there's an appropriate entry in the @@ -219,6 +218,11 @@ class LocMapperStore(object): return None result = None for candidate in maps: + if get_course and 'name' in candidate['_id']: + candidate_id = candidate['_id'] + return Location( + 'i4x', candidate_id['org'], candidate_id['course'], 'course', candidate_id['name'] + ) old_course_id = self._generate_location_course_id(candidate['_id']) for old_name, cat_to_usage in candidate['block_map'].iteritems(): for category, block_id in cat_to_usage.iteritems(): @@ -240,7 +244,7 @@ class LocMapperStore(object): candidate['course_id'], branch=candidate['draft_branch'], block_id=block_id ) self._cache_location_map_entry(old_course_id, location, published_locator, draft_locator) - + if get_course and category == 'course': result = location elif not get_course and block_id == locator.block_id: @@ -261,8 +265,6 @@ class LocMapperStore(object): return cached location_id = self._interpret_location_course_id(old_style_course_id, location) - if old_style_course_id is None: - old_style_course_id = self._generate_location_course_id(location_id) maps = self.location_map.find(location_id) maps = list(maps) @@ -320,21 +322,21 @@ class LocMapperStore(object): return {'_id': self._construct_location_son(location.org, location.course, location.name)} else: return bson.son.SON([('_id.org', location.org), ('_id.course', location.course)]) - + def _generate_location_course_id(self, entry_id): """ - Generate a Location course_id for the given entry's id + Generate a Location course_id for the given entry's id. """ # strip id envelope if any entry_id = entry_id.get('_id', entry_id) if entry_id.get('name', False): - return '{0[org]}/{0[course]}/{0[name]}'.format(entry_id) + return u'{0[org]}/{0[course]}/{0[name]}'.format(entry_id) elif entry_id.get('_id.org', False): # the odd format one - return '{0[_id.org]}/{0[_id.course]}'.format(entry_id) + return u'{0[_id.org]}/{0[_id.course]}'.format(entry_id) else: - return '{0[org]}/{0[course]}'.format(entry_id) - + return u'{0[org]}/{0[course]}'.format(entry_id) + def _construct_location_son(self, org, course, name=None): """ Construct the SON needed to repr the location for either a query or an insertion @@ -389,7 +391,7 @@ class LocMapperStore(object): """ See if the location x published pair is in the cache. If so, return the mapped locator. """ - entry = self.cache.get('{}+{}'.format(old_course_id, location.url())) + entry = self.cache.get(u'{}+{}'.format(old_course_id, location.url())) if entry is not None: if published: return entry[0] @@ -401,6 +403,8 @@ class LocMapperStore(object): """ Get the course Locator for this old course id """ + if not old_course_id: + return None entry = self.cache.get(old_course_id) if entry is not None: if published: @@ -419,12 +423,14 @@ class LocMapperStore(object): See if the package_id is in the cache. If so, return the mapped location to the course root. """ - return self.cache.get('courseId+{}'.format(locator_package_id)) + return self.cache.get(u'courseId+{}'.format(locator_package_id)) def _cache_course_locator(self, old_course_id, published_course_locator, draft_course_locator): """ For quick lookup of courses """ + if not old_course_id: + return self.cache.set(old_course_id, (published_course_locator, draft_course_locator)) def _cache_location_map_entry(self, old_course_id, location, published_usage, draft_usage): @@ -435,9 +441,9 @@ class LocMapperStore(object): """ setmany = {} if location.category == 'course': - setmany['courseId+{}'.format(published_usage.package_id)] = location + setmany[u'courseId+{}'.format(published_usage.package_id)] = location setmany[unicode(published_usage)] = location setmany[unicode(draft_usage)] = location - setmany['{}+{}'.format(old_course_id, location.url())] = (published_usage, draft_usage) + setmany[u'{}+{}'.format(old_course_id, location.url())] = (published_usage, draft_usage) setmany[old_course_id] = (published_usage, draft_usage) self.cache.set_many(setmany) diff --git a/common/lib/xmodule/xmodule/modulestore/locator.py b/common/lib/xmodule/xmodule/modulestore/locator.py index bde6cee838..2b58797532 100644 --- a/common/lib/xmodule/xmodule/modulestore/locator.py +++ b/common/lib/xmodule/xmodule/modulestore/locator.py @@ -64,13 +64,13 @@ class Locator(object): ''' str(self) returns something like this: "mit.eecs.6002x" ''' - return unicode(self).encode('utf8') + return unicode(self).encode('utf-8') def __unicode__(self): ''' unicode(self) returns something like this: "mit.eecs.6002x" ''' - return self.url() + return unicode(self).encode('utf-8') @abstractmethod def version(self): @@ -199,12 +199,12 @@ class CourseLocator(Locator): Return a string representing this location. """ if self.package_id: - result = self.package_id + result = unicode(self.package_id) if self.branch: result += '/' + BRANCH_PREFIX + self.branch return result elif self.version_guid: - return VERSION_PREFIX + str(self.version_guid) + return u"{prefix}{guid}".format(prefix=VERSION_PREFIX, guid=self.version_guid) else: # raise InsufficientSpecificationError("missing package_id or version_guid") return '' @@ -213,7 +213,7 @@ class CourseLocator(Locator): """ Return a string containing the URL for this location. """ - return 'edx://' + unicode(self) + return u'edx://' + unicode(self) def _validate_args(self, url, version_guid, package_id): """ @@ -526,7 +526,7 @@ class DefinitionLocator(Locator): Return a string containing the URL for this location. url(self) returns something like this: 'defx://version/519665f6223ebd6980884f2b' """ - return 'defx://' + unicode(self) + return u'defx://' + unicode(self) def version(self): """ diff --git a/common/lib/xmodule/xmodule/modulestore/parsers.py b/common/lib/xmodule/xmodule/modulestore/parsers.py index f1f49febb4..9d046effdb 100644 --- a/common/lib/xmodule/xmodule/modulestore/parsers.py +++ b/common/lib/xmodule/xmodule/modulestore/parsers.py @@ -7,7 +7,8 @@ BLOCK_PREFIX = r"block/" # Prefix for the version portion of a locator URL, when it is preceded by a course ID VERSION_PREFIX = r"version/" -ALLOWED_ID_CHARS = r'[a-zA-Z0-9_\-~.:]' +ALLOWED_ID_CHARS = r'[\w\-~.:]' + URL_RE_SOURCE = r""" (?Pedx://)? @@ -20,7 +21,7 @@ URL_RE_SOURCE = r""" VERSION_PREFIX=VERSION_PREFIX, BLOCK_PREFIX=BLOCK_PREFIX ) -URL_RE = re.compile('^' + URL_RE_SOURCE + '$', re.IGNORECASE | re.VERBOSE) +URL_RE = re.compile('^' + URL_RE_SOURCE + '$', re.IGNORECASE | re.VERBOSE | re.UNICODE) def parse_url(string, tag_optional=False): @@ -54,7 +55,7 @@ def parse_url(string, tag_optional=False): return matched_dict -BLOCK_RE = re.compile(r'^' + ALLOWED_ID_CHARS + r'+$', re.IGNORECASE) +BLOCK_RE = re.compile(r'^' + ALLOWED_ID_CHARS + r'+$', re.IGNORECASE | re.UNICODE) def parse_block_ref(string): diff --git a/common/lib/xmodule/xmodule/modulestore/split_migrator.py b/common/lib/xmodule/xmodule/modulestore/split_migrator.py index c3c7ed6a2f..e377e7355c 100644 --- a/common/lib/xmodule/xmodule/modulestore/split_migrator.py +++ b/common/lib/xmodule/xmodule/modulestore/split_migrator.py @@ -23,7 +23,7 @@ class SplitMigrator(object): self.draft_modulestore = draft_modulestore self.loc_mapper = loc_mapper - def migrate_mongo_course(self, course_location, user_id, new_package_id=None): + def migrate_mongo_course(self, course_location, user, new_package_id=None): """ Create a new course in split_mongo representing the published and draft versions of the course from the original mongo store. And return the new_package_id (which the caller can also get by calling @@ -32,7 +32,7 @@ class SplitMigrator(object): If the new course already exists, this raises DuplicateItemError :param course_location: a Location whose category is 'course' and points to the course - :param user_id: the user whose action is causing this migration + :param user: the user whose action is causing this migration :param new_package_id: (optional) the Locator.package_id for the new course. Defaults to whatever translate_location_to_locator returns """ @@ -48,18 +48,18 @@ class SplitMigrator(object): new_course_root_locator = self.loc_mapper.translate_location(old_course_id, course_location) new_course = self.split_modulestore.create_course( course_location.org, original_course.display_name, - user_id, id_root=new_package_id, + user.id, id_root=new_package_id, fields=self._get_json_fields_translate_children(original_course, old_course_id, True), root_block_id=new_course_root_locator.block_id, master_branch=new_course_root_locator.branch ) - self._copy_published_modules_to_course(new_course, course_location, old_course_id, user_id) - self._add_draft_modules_to_course(new_package_id, old_course_id, course_location, user_id) + self._copy_published_modules_to_course(new_course, course_location, old_course_id, user) + self._add_draft_modules_to_course(new_package_id, old_course_id, course_location, user) return new_package_id - def _copy_published_modules_to_course(self, new_course, old_course_loc, old_course_id, user_id): + def _copy_published_modules_to_course(self, new_course, old_course_loc, old_course_id, user): """ Copy all of the modules from the 'direct' version of the course to the new split course. """ @@ -79,7 +79,7 @@ class SplitMigrator(object): old_course_id, module.location, True, add_entry_if_missing=True ) _new_module = self.split_modulestore.create_item( - course_version_locator, module.category, user_id, + course_version_locator, module.category, user.id, block_id=new_locator.block_id, fields=self._get_json_fields_translate_children(module, old_course_id, True), continue_version=True @@ -94,7 +94,7 @@ class SplitMigrator(object): # children which meant some pointers were to non-existent locations in 'direct' self.split_modulestore.internal_clean_children(course_version_locator) - def _add_draft_modules_to_course(self, new_package_id, old_course_id, old_course_loc, user_id): + def _add_draft_modules_to_course(self, new_package_id, old_course_id, old_course_loc, user): """ update each draft. Create any which don't exist in published and attach to their parents. """ @@ -124,12 +124,12 @@ class SplitMigrator(object): if name != 'children' and field.is_set_on(module): field.write_to(split_module, field.read_from(module)) - _new_module = self.split_modulestore.update_item(split_module, user_id) + _new_module = self.split_modulestore.update_item(split_module, user.id) else: # only a draft version (aka, 'private'). parent needs updated too. # create a new course version just in case the current head is also the prod head _new_module = self.split_modulestore.create_item( - new_draft_course_loc, module.category, user_id, + new_draft_course_loc, module.category, user.id, block_id=new_locator.block_id, fields=self._get_json_fields_translate_children(module, old_course_id, True) ) @@ -156,7 +156,7 @@ class SplitMigrator(object): new_parent_cursor = idx + 1 break new_parent.children.insert(new_parent_cursor, new_block_id) - new_parent = self.split_modulestore.update_item(new_parent, user_id) + new_parent = self.split_modulestore.update_item(new_parent, user.id) def _get_json_fields_translate_children(self, xblock, old_course_id, published): """ diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py index 5f5f047f1b..279002e473 100644 --- a/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py +++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py @@ -1284,6 +1284,7 @@ class SplitMongoModuleStore(ModuleStoreWriteBase): if index is None: raise ItemNotFoundError(package_id) # this is the only real delete in the system. should it do something else? + log.info("deleting course from split-mongo: %s", package_id) self.db_connection.delete_course_index(index['_id']) def get_errored_courses(self): diff --git a/common/lib/xmodule/xmodule/modulestore/store_utilities.py b/common/lib/xmodule/xmodule/modulestore/store_utilities.py index a01eb5e5f1..9408dc14fd 100644 --- a/common/lib/xmodule/xmodule/modulestore/store_utilities.py +++ b/common/lib/xmodule/xmodule/modulestore/store_utilities.py @@ -73,13 +73,13 @@ def rewrite_nonportable_content_links(source_course_id, dest_course_id, text): # NOTE: ultimately link updating is not a hard requirement, so if something blows up with # the regex subsitution, log the error and continue try: - c4x_link_base = '{0}/'.format(StaticContent.get_base_url_path_for_course_assets(course_location)) + c4x_link_base = u'{0}/'.format(StaticContent.get_base_url_path_for_course_assets(course_location)) text = re.sub(_prefix_only_url_replace_regex(c4x_link_base), portable_asset_link_subtitution, text) except Exception, e: logging.warning("Error going regex subtituion %r on text = %r.\n\nError msg = %s", c4x_link_base, text, str(e)) try: - jump_to_link_base = '/courses/{org}/{course}/{run}/jump_to/i4x://{org}/{course}/'.format( + jump_to_link_base = u'/courses/{org}/{course}/{run}/jump_to/i4x://{org}/{course}/'.format( org=org, course=course, run=run ) text = re.sub(_prefix_and_category_url_replace_regex(jump_to_link_base), portable_jump_to_link_substitution, text) @@ -94,7 +94,7 @@ def rewrite_nonportable_content_links(source_course_id, dest_course_id, text): # if source_course_id != dest_course_id: try: - generic_courseware_link_base = '/courses/{org}/{course}/{run}/'.format( + generic_courseware_link_base = u'/courses/{org}/{course}/{run}/'.format( org=org, course=course, run=run ) text = re.sub(_prefix_only_url_replace_regex(generic_courseware_link_base), portable_asset_link_subtitution, text) diff --git a/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py index 684664890e..88bc6c87d9 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py @@ -4,8 +4,8 @@ Modulestore configuration for test cases. from uuid import uuid4 from django.test import TestCase -from xmodule.modulestore.django import editable_modulestore, \ - clear_existing_modulestores +from xmodule.modulestore.django import ( + editable_modulestore, clear_existing_modulestores, loc_mapper) from xmodule.contentstore.django import contentstore @@ -225,6 +225,9 @@ class ModuleStoreTestCase(TestCase): if contentstore().fs_files: db = contentstore().fs_files.database db.connection.drop_database(db) + location_mapper = loc_mapper() + if location_mapper.db: + location_mapper.location_map.drop() @classmethod def setUpClass(cls): diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_location.py b/common/lib/xmodule/xmodule/modulestore/tests/test_location.py index 5f4983e1bf..f0c815f41c 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_location.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_location.py @@ -19,6 +19,9 @@ GENERAL_PAIRS = [ @ddt.ddt class TestLocations(TestCase): + """ + Tests of :class:`.Location` + """ @ddt.data( "tag://org/course/category/name", "tag://org/course/category/name@revision" @@ -173,6 +176,8 @@ class TestLocations(TestCase): loc.course_id # pylint: disable=pointless-statement def test_replacement(self): + # pylint: disable=protected-access + self.assertEquals( Location('t://o/c/c/n@r')._replace(name='new_name'), Location('t://o/c/c/new_name@r'), diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_location_mapper.py b/common/lib/xmodule/xmodule/modulestore/tests/test_location_mapper.py index 90da230e76..a0e869d9fe 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_location_mapper.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_location_mapper.py @@ -80,8 +80,8 @@ class TestLocationMapper(unittest.TestCase): Request translation, check package_id, block_id, and branch """ prob_locator = loc_mapper().translate_location( - old_style_course_id, - location, + old_style_course_id, + location, published= (branch=='published'), add_entry_if_missing=add_entry ) @@ -114,7 +114,7 @@ class TestLocationMapper(unittest.TestCase): new_style_package_id = '{}.geek_dept.{}.baz_run'.format(org, course) block_map = { - 'abc123': {'problem': 'problem2'}, + 'abc123': {'problem': 'problem2'}, 'def456': {'problem': 'problem4'}, 'ghi789': {'problem': 'problem7'}, } @@ -139,7 +139,7 @@ class TestLocationMapper(unittest.TestCase): # add a distractor course (note that abc123 has a different translation in this one) distractor_block_map = { - 'abc123': {'problem': 'problem3'}, + 'abc123': {'problem': 'problem3'}, 'def456': {'problem': 'problem4'}, 'ghi789': {'problem': 'problem7'}, } diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_split_migrator.py b/common/lib/xmodule/xmodule/modulestore/tests/test_split_migrator.py index 98c64dcac5..0341d43436 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_split_migrator.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_split_migrator.py @@ -271,7 +271,8 @@ class TestMigration(unittest.TestCase): self.compare_dags(presplit, pre_child, split_child, published) def test_migrator(self): - self.migrator.migrate_mongo_course(self.course_location, random.getrandbits(32)) + user = mock.Mock(id=1) + self.migrator.migrate_mongo_course(self.course_location, user) # now compare the migrated to the original course self.compare_courses(self.old_mongo, True) self.compare_courses(self.draft_mongo, False) diff --git a/common/lib/xmodule/xmodule/modulestore/xml_exporter.py b/common/lib/xmodule/xmodule/modulestore/xml_exporter.py index 2b5efcb87f..e9360a53de 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml_exporter.py +++ b/common/lib/xmodule/xmodule/modulestore/xml_exporter.py @@ -9,7 +9,14 @@ from fs.osfs import OSFS from json import dumps import json import datetime +import os +from path import path +import shutil +DRAFT_DIR = "drafts" +PUBLISHED_DIR = "published" +EXPORT_VERSION_FILE = "format.json" +EXPORT_VERSION_KEY = "export_format" class EdxJSONEncoder(json.JSONEncoder): """ @@ -95,7 +102,7 @@ def export_to_xml(modulestore, contentstore, course_location, root_dir, course_d draft_verticals = draft_modulestore.get_items([None, course_location.org, course_location.course, 'vertical', None, 'draft']) if len(draft_verticals) > 0: - draft_course_dir = export_fs.makeopendir('drafts') + draft_course_dir = export_fs.makeopendir(DRAFT_DIR) for draft_vertical in draft_verticals: parent_locs = draft_modulestore.get_parent_locations(draft_vertical.location, course.location.course_id) # Don't try to export orphaned items. @@ -117,3 +124,90 @@ def export_extra_content(export_fs, modulestore, course_id, course_location, cat for item in items: with item_dir.open(item.location.name + file_suffix, 'w') as item_file: item_file.write(item.data.encode('utf8')) + + +def convert_between_versions(source_dir, target_dir): + """ + Converts a version 0 export format to version 1, and vice versa. + + @param source_dir: the directory structure with the course export that should be converted. + The contents of source_dir will not be altered. + @param target_dir: the directory where the converted export should be written. + @return: the version number of the converted export. + """ + def convert_to_version_1(): + """ Convert a version 0 archive to version 0 """ + os.mkdir(copy_root) + with open(copy_root / EXPORT_VERSION_FILE, 'w') as f: + f.write('{{"{export_key}": 1}}\n'.format(export_key=EXPORT_VERSION_KEY)) + + # If a drafts folder exists, copy it over. + copy_drafts() + + # Now copy everything into the published directory + published_dir = copy_root / PUBLISHED_DIR + shutil.copytree(path(source_dir) / course_name, published_dir) + # And delete the nested drafts directory, if it exists. + nested_drafts_dir = published_dir / DRAFT_DIR + if nested_drafts_dir.isdir(): + shutil.rmtree(nested_drafts_dir) + + def convert_to_version_0(): + """ Convert a version 1 archive to version 0 """ + # Copy everything in "published" up to the top level. + published_dir = path(source_dir) / course_name / PUBLISHED_DIR + if not published_dir.isdir(): + raise ValueError("a version 1 archive must contain a published branch") + + shutil.copytree(published_dir, copy_root) + + # If there is a "draft" branch, copy it. All other branches are ignored. + copy_drafts() + + def copy_drafts(): + """ + Copy drafts directory from the old archive structure to the new. + """ + draft_dir = path(source_dir) / course_name / DRAFT_DIR + if draft_dir.isdir(): + shutil.copytree(draft_dir, copy_root / DRAFT_DIR) + + root = os.listdir(source_dir) + if len(root) != 1 or (path(source_dir) / root[0]).isfile(): + raise ValueError("source archive does not have single course directory at top level") + + course_name = root[0] + + # For this version of the script, we simply convert back and forth between version 0 and 1. + original_version = get_version(path(source_dir) / course_name) + if original_version not in [0, 1]: + raise ValueError("unknown version: " + str(original_version)) + desired_version = 1 if original_version is 0 else 0 + + copy_root = path(target_dir) / course_name + + if desired_version == 1: + convert_to_version_1() + else: + convert_to_version_0() + + return desired_version + + +def get_version(course_path): + """ + Return the export format version number for the given + archive directory structure (represented as a path instance). + + If the archived file does not correspond to a known export + format, None will be returned. + """ + format_file = course_path / EXPORT_VERSION_FILE + if not format_file.isfile(): + return 0 + with open(format_file, "r") as f: + data = json.load(f) + if EXPORT_VERSION_KEY in data: + return data[EXPORT_VERSION_KEY] + + return None diff --git a/common/lib/xmodule/xmodule/tests/__init__.py b/common/lib/xmodule/xmodule/tests/__init__.py index 543d0eb095..caac60e71f 100644 --- a/common/lib/xmodule/xmodule/tests/__init__.py +++ b/common/lib/xmodule/xmodule/tests/__init__.py @@ -77,6 +77,7 @@ def get_test_system(course_id=''): open_ended_grading_interface=open_ended_grading_interface, course_id=course_id, error_descriptor_class=ErrorDescriptor, + get_user_role=Mock(is_staff=False), ) diff --git a/common/lib/xmodule/xmodule/tests/data/EmptyCourse.tar.gz b/common/lib/xmodule/xmodule/tests/data/EmptyCourse.tar.gz new file mode 100644 index 0000000000..b81f01a2cb Binary files /dev/null and b/common/lib/xmodule/xmodule/tests/data/EmptyCourse.tar.gz differ diff --git a/common/lib/xmodule/xmodule/tests/data/NoVersionNumber.tar.gz b/common/lib/xmodule/xmodule/tests/data/NoVersionNumber.tar.gz new file mode 100644 index 0000000000..ab61274c0a Binary files /dev/null and b/common/lib/xmodule/xmodule/tests/data/NoVersionNumber.tar.gz differ diff --git a/common/lib/xmodule/xmodule/tests/data/Version0_drafts.tar.gz b/common/lib/xmodule/xmodule/tests/data/Version0_drafts.tar.gz new file mode 100644 index 0000000000..e55649b1da Binary files /dev/null and b/common/lib/xmodule/xmodule/tests/data/Version0_drafts.tar.gz differ diff --git a/common/lib/xmodule/xmodule/tests/data/Version0_nodrafts.tar.gz b/common/lib/xmodule/xmodule/tests/data/Version0_nodrafts.tar.gz new file mode 100644 index 0000000000..93e501d047 Binary files /dev/null and b/common/lib/xmodule/xmodule/tests/data/Version0_nodrafts.tar.gz differ diff --git a/common/lib/xmodule/xmodule/tests/data/Version1_drafts.tar.gz b/common/lib/xmodule/xmodule/tests/data/Version1_drafts.tar.gz new file mode 100644 index 0000000000..4cf81903c8 Binary files /dev/null and b/common/lib/xmodule/xmodule/tests/data/Version1_drafts.tar.gz differ diff --git a/common/lib/xmodule/xmodule/tests/data/Version1_drafts_extra_branch.tar.gz b/common/lib/xmodule/xmodule/tests/data/Version1_drafts_extra_branch.tar.gz new file mode 100644 index 0000000000..8c4ec41023 Binary files /dev/null and b/common/lib/xmodule/xmodule/tests/data/Version1_drafts_extra_branch.tar.gz differ diff --git a/common/lib/xmodule/xmodule/tests/data/Version1_nodrafts.tar.gz b/common/lib/xmodule/xmodule/tests/data/Version1_nodrafts.tar.gz new file mode 100644 index 0000000000..b6673ba1a2 Binary files /dev/null and b/common/lib/xmodule/xmodule/tests/data/Version1_nodrafts.tar.gz differ diff --git a/common/lib/xmodule/xmodule/tests/data/Version1_nopublished.tar.gz b/common/lib/xmodule/xmodule/tests/data/Version1_nopublished.tar.gz new file mode 100644 index 0000000000..55e8e13d0f Binary files /dev/null and b/common/lib/xmodule/xmodule/tests/data/Version1_nopublished.tar.gz differ diff --git a/common/lib/xmodule/xmodule/tests/helpers.py b/common/lib/xmodule/xmodule/tests/helpers.py new file mode 100644 index 0000000000..b8f56445c2 --- /dev/null +++ b/common/lib/xmodule/xmodule/tests/helpers.py @@ -0,0 +1,26 @@ +""" +Utility methods for unit tests. +""" + +import filecmp +from path import path + + +def directories_equal(directory1, directory2): + """ + Returns True if the 2 directories have equal content, else false. + """ + def compare_dirs(dir1, dir2): + """ Compare directories for equality. """ + comparison = filecmp.dircmp(dir1, dir2) + if (len(comparison.left_only) > 0) or (len(comparison.right_only) > 0): + return False + if (len(comparison.funny_files) > 0) or (len(comparison.diff_files) > 0): + return False + for subdir in comparison.subdirs: + if not compare_dirs(dir1 / subdir, dir2 / subdir): + return False + + return True + + return compare_dirs(path(directory1), path(directory2)) diff --git a/common/lib/xmodule/xmodule/tests/test_capa_module.py b/common/lib/xmodule/xmodule/tests/test_capa_module.py index 6bb82f3d47..8c103e0ed5 100644 --- a/common/lib/xmodule/xmodule/tests/test_capa_module.py +++ b/common/lib/xmodule/xmodule/tests/test_capa_module.py @@ -1349,6 +1349,18 @@ class CapaModuleTest(unittest.TestCase): mock_log.exception.assert_called_once_with('Got bad progress') mock_log.reset_mock() + @patch('xmodule.capa_base.Progress') + def test_get_progress_no_error_if_weight_zero(self, mock_progress): + """ + Check that if the weight is 0 get_progress does not try to create a Progress object. + """ + mock_progress.return_value = True + module = CapaFactory.create() + module.weight = 0 + progress = module.get_progress() + self.assertIsNone(progress) + self.assertFalse(mock_progress.called) + @patch('xmodule.capa_base.Progress') def test_get_progress_calculate_progress_fraction(self, mock_progress): """ diff --git a/common/lib/xmodule/xmodule/tests/test_conditional.py b/common/lib/xmodule/xmodule/tests/test_conditional.py index 74aa83073f..0487194dbc 100644 --- a/common/lib/xmodule/xmodule/tests/test_conditional.py +++ b/common/lib/xmodule/xmodule/tests/test_conditional.py @@ -129,9 +129,9 @@ class ConditionalModuleBasicTest(unittest.TestCase): html = modules['cond_module'].render('student_view').content expected = modules['cond_module'].xmodule_runtime.render_template('conditional_ajax.html', { 'ajax_url': modules['cond_module'].xmodule_runtime.ajax_url, - 'element_id': 'i4x-edX-conditional_test-conditional-SampleConditional', - 'id': 'i4x://edX/conditional_test/conditional/SampleConditional', - 'depends': 'i4x-edX-conditional_test-problem-SampleProblem', + 'element_id': u'i4x-edX-conditional_test-conditional-SampleConditional', + 'id': u'i4x://edX/conditional_test/conditional/SampleConditional', + 'depends': u'i4x-edX-conditional_test-problem-SampleProblem', }) self.assertEquals(expected, html) @@ -225,9 +225,9 @@ class ConditionalModuleXmlTest(unittest.TestCase): { # Test ajax url is just usage-id / handler_name 'ajax_url': 'i4x://HarvardX/ER22x/conditional/condone/xmodule_handler', - 'element_id': 'i4x-HarvardX-ER22x-conditional-condone', - 'id': 'i4x://HarvardX/ER22x/conditional/condone', - 'depends': 'i4x-HarvardX-ER22x-problem-choiceprob' + 'element_id': u'i4x-HarvardX-ER22x-conditional-condone', + 'id': u'i4x://HarvardX/ER22x/conditional/condone', + 'depends': u'i4x-HarvardX-ER22x-problem-choiceprob' } ) self.assertEqual(html, html_expect) diff --git a/common/lib/xmodule/xmodule/tests/test_export.py b/common/lib/xmodule/xmodule/tests/test_export.py index 1ee059f9fe..a1fe66af39 100644 --- a/common/lib/xmodule/xmodule/tests/test_export.py +++ b/common/lib/xmodule/xmodule/tests/test_export.py @@ -12,11 +12,17 @@ import mock import pytz from fs.osfs import OSFS from path import path +import uuid +import tarfile +import os from xmodule.modulestore import Location from xmodule.modulestore.xml import XMLModuleStore -from xmodule.modulestore.xml_exporter import EdxJSONEncoder +from xmodule.modulestore.xml_exporter import ( + EdxJSONEncoder, convert_between_versions, get_version +) from xmodule.tests import DATA_DIR +from xmodule.tests.helpers import directories_equal def strip_filenames(descriptor): @@ -195,3 +201,132 @@ class TestEdxJsonEncoder(unittest.TestCase): with self.assertRaises(TypeError): self.encoder.default({}) + + +class ConvertExportFormat(unittest.TestCase): + """ + Tests converting between export formats. + """ + def setUp(self): + """ Common setup. """ + + # Directory for expanding all the test archives + self.temp_dir = mkdtemp() + + # Directory where new archive will be created + self.result_dir = path(self.temp_dir) / uuid.uuid4().hex + os.mkdir(self.result_dir) + + # Expand all the test archives and store their paths. + self.data_dir = path(__file__).realpath().parent / 'data' + self.version0_nodrafts = self._expand_archive('Version0_nodrafts.tar.gz') + self.version1_nodrafts = self._expand_archive('Version1_nodrafts.tar.gz') + self.version0_drafts = self._expand_archive('Version0_drafts.tar.gz') + self.version1_drafts = self._expand_archive('Version1_drafts.tar.gz') + self.version1_drafts_extra_branch = self._expand_archive('Version1_drafts_extra_branch.tar.gz') + self.no_version = self._expand_archive('NoVersionNumber.tar.gz') + + def tearDown(self): + """ Common cleanup. """ + shutil.rmtree(self.temp_dir) + + def _expand_archive(self, name): + """ Expand archive into a directory and return the directory. """ + target = path(self.temp_dir) / uuid.uuid4().hex + os.mkdir(target) + with tarfile.open(self.data_dir / name) as tar_file: + tar_file.extractall(path=target) + + return target + + def test_no_version(self): + """ Test error condition of no version number specified. """ + errstring = "unknown version" + with self.assertRaisesRegexp(ValueError, errstring): + convert_between_versions(self.no_version, self.result_dir) + + def test_no_published(self): + """ Test error condition of a version 1 archive with no published branch. """ + errstring = "version 1 archive must contain a published branch" + no_published = self._expand_archive('Version1_nopublished.tar.gz') + with self.assertRaisesRegexp(ValueError, errstring): + convert_between_versions(no_published, self.result_dir) + + def test_empty_course(self): + """ Test error condition of a version 1 archive with no published branch. """ + errstring = "source archive does not have single course directory at top level" + empty_course = self._expand_archive('EmptyCourse.tar.gz') + with self.assertRaisesRegexp(ValueError, errstring): + convert_between_versions(empty_course, self.result_dir) + + def test_convert_to_1_nodrafts(self): + """ + Test for converting from version 0 of export format to version 1 in a course with no drafts. + """ + self._verify_conversion(self.version0_nodrafts, self.version1_nodrafts) + + def test_convert_to_1_drafts(self): + """ + Test for converting from version 0 of export format to version 1 in a course with drafts. + """ + self._verify_conversion(self.version0_drafts, self.version1_drafts) + + def test_convert_to_0_nodrafts(self): + """ + Test for converting from version 1 of export format to version 0 in a course with no drafts. + """ + self._verify_conversion(self.version1_nodrafts, self.version0_nodrafts) + + def test_convert_to_0_drafts(self): + """ + Test for converting from version 1 of export format to version 0 in a course with drafts. + """ + self._verify_conversion(self.version1_drafts, self.version0_drafts) + + def test_convert_to_0_extra_branch(self): + """ + Test for converting from version 1 of export format to version 0 in a course + with drafts and an extra branch. + """ + self._verify_conversion(self.version1_drafts_extra_branch, self.version0_drafts) + + def test_equality_function(self): + """ + Check equality function returns False for unequal directories. + """ + self.assertFalse(directories_equal(self.version1_nodrafts, self.version0_nodrafts)) + self.assertFalse(directories_equal(self.version1_drafts_extra_branch, self.version1_drafts)) + + def test_version_0(self): + """ + Check that get_version correctly identifies a version 0 archive (old format). + """ + self.assertEqual(0, self._version_test(self.version0_nodrafts)) + + def test_version_1(self): + """ + Check that get_version correctly identifies a version 1 archive (new format). + """ + self.assertEqual(1, self._version_test(self.version1_nodrafts)) + + def test_version_missing(self): + """ + Check that get_version returns None if no version number is specified, + and the archive is not version 0. + """ + self.assertIsNone(self._version_test(self.no_version)) + + def _version_test(self, archive_dir): + """ + Helper function for version tests. + """ + root = os.listdir(archive_dir) + course_directory = archive_dir / root[0] + return get_version(course_directory) + + def _verify_conversion(self, source_archive, comparison_archive): + """ + Helper function for conversion tests. + """ + convert_between_versions(source_archive, self.result_dir) + self.assertTrue(directories_equal(self.result_dir, comparison_archive)) diff --git a/common/lib/xmodule/xmodule/tests/test_import.py b/common/lib/xmodule/xmodule/tests/test_import.py index 994603e2e1..12f889d897 100644 --- a/common/lib/xmodule/xmodule/tests/test_import.py +++ b/common/lib/xmodule/xmodule/tests/test_import.py @@ -78,6 +78,7 @@ class BaseCourseTestCase(unittest.TestCase): class GenericXBlock(XBlock): + """XBlock for testing pure xblock xml import""" has_children = True field1 = String(default="something", scope=Scope.user_state) field2 = Integer(scope=Scope.user_state) @@ -85,6 +86,9 @@ class GenericXBlock(XBlock): @ddt.ddt class PureXBlockImportTest(BaseCourseTestCase): + """ + Tests of import pure XBlocks (not XModules) from xml + """ def assert_xblocks_are_good(self, block): """Assert a number of conditions that must be true for `block` to be good.""" diff --git a/common/lib/xmodule/xmodule/tests/test_xml_module.py b/common/lib/xmodule/xmodule/tests/test_xml_module.py index 62ebf4dce0..d8192d7a8d 100644 --- a/common/lib/xmodule/xmodule/tests/test_xml_module.py +++ b/common/lib/xmodule/xmodule/tests/test_xml_module.py @@ -119,13 +119,11 @@ class InheritingFieldDataTest(unittest.TestCase): parent = self.get_a_block(usage_id="parent") parent.inherited = "Changed!" self.assertEqual(parent.inherited, "Changed!") - parent_id = "parent" for child_num in range(10): usage_id = "child_{}".format(child_num) child = self.get_a_block(usage_id=usage_id) child.parent = "parent" self.assertEqual(child.inherited, "Changed!") - parent_id = usage_id def test_not_inherited(self): # Fields not in the inherited_names list won't be inherited. diff --git a/common/lib/xmodule/xmodule/video_module.py b/common/lib/xmodule/xmodule/video_module.py index c6a698c5f2..9764066141 100644 --- a/common/lib/xmodule/xmodule/video_module.py +++ b/common/lib/xmodule/xmodule/video_module.py @@ -233,7 +233,8 @@ class VideoModule(VideoFields, XModule): 'id': self.location.html_id(), 'show_captions': json.dumps(self.show_captions), 'sources': sources, - 'speed': self.speed or self.global_speed, + 'speed': json.dumps(self.speed), + 'general_speed': self.global_speed, 'start': self.start_time.total_seconds(), 'sub': self.sub, 'track': track_url, diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index ac91ecb119..9fe208fd04 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -223,7 +223,7 @@ class XModuleMixin(XBlockMixin): try: child = self.runtime.get_block(child_loc) except ItemNotFoundError: - log.exception('Unable to load item {loc}, skipping'.format(loc=child_loc)) + log.exception(u'Unable to load item {loc}, skipping'.format(loc=child_loc)) continue self._child_instances.append(child) @@ -538,7 +538,6 @@ class ResourceTemplates(object): template = yaml.safe_load(template_content) template['template_id'] = template_file templates.append(template) - return templates @classmethod @@ -546,7 +545,7 @@ class ResourceTemplates(object): if getattr(cls, 'template_dir_name', None): dirname = os.path.join('templates', cls.template_dir_name) if not resource_isdir(__name__, dirname): - log.warning("No resource directory {dir} found when loading {cls_name} templates".format( + log.warning(u"No resource directory {dir} found when loading {cls_name} templates".format( dir=dirname, cls_name=cls.__name__, )) @@ -988,12 +987,15 @@ class DescriptorSystem(ConfigurableFragmentWrapper, Runtime): # pylint: disable # global function that the application can override. return descriptor_global_handler_url(block, handler_name, suffix, query, thirdparty) - def resources_url(self, resource): + def resource_url(self, resource): raise NotImplementedError("edX Platform doesn't currently implement XBlock resource urls") def local_resource_url(self, block, uri): raise NotImplementedError("edX Platform doesn't currently implement XBlock resource urls") + def publish(self, block, event): + raise NotImplementedError("edX Platform doesn't currently implement XBlock publish") + class XMLParsingSystem(DescriptorSystem): def __init__(self, process_xml, **kwargs): @@ -1026,7 +1028,7 @@ class ModuleSystem(ConfigurableFragmentWrapper, Runtime): # pylint: disable=abs open_ended_grading_interface=None, s3_interface=None, cache=None, can_execute_unsafe_code=None, replace_course_urls=None, replace_jump_to_id_urls=None, error_descriptor_class=None, get_real_user=None, - field_data=None, + field_data=None, get_user_role=None, **kwargs): """ Create a closure around the system environment. @@ -1079,6 +1081,9 @@ class ModuleSystem(ConfigurableFragmentWrapper, Runtime): # pylint: disable=abs get_real_user - function that takes `anonymous_student_id` and returns real user_id, associated with `anonymous_student_id`. + get_user_role - A function that returns user role. Implementation is different + for LMS and Studio. + field_data - the `FieldData` to use for backing XBlock storage. """ @@ -1101,10 +1106,8 @@ class ModuleSystem(ConfigurableFragmentWrapper, Runtime): # pylint: disable=abs self.course_id = course_id self.user_is_staff = user is not None and user.is_staff - if publish is None: - publish = lambda e: None - - self.publish = publish + if publish: + self.publish = publish self.open_ended_grading_interface = open_ended_grading_interface self.s3_interface = s3_interface @@ -1118,6 +1121,8 @@ class ModuleSystem(ConfigurableFragmentWrapper, Runtime): # pylint: disable=abs self.get_real_user = get_real_user + self.get_user_role = get_user_role + def get(self, attr): """ provide uniform access to attributes (like etree).""" return self.__dict__.get(attr) @@ -1143,12 +1148,15 @@ class ModuleSystem(ConfigurableFragmentWrapper, Runtime): # pylint: disable=abs def get_block(self, block_id): raise NotImplementedError("XModules must use get_module to load other modules") - def resources_url(self, resource): + def resource_url(self, resource): raise NotImplementedError("edX Platform doesn't currently implement XBlock resource urls") def local_resource_url(self, block, uri): raise NotImplementedError("edX Platform doesn't currently implement XBlock resource urls") + def publish(self, block, event): + pass + class DoNothingCache(object): """A duck-compatible object to use in ModuleSystem when there's no cache.""" diff --git a/common/lib/xmodule/xmodule/xml_module.py b/common/lib/xmodule/xmodule/xml_module.py index c659b7787b..d4ea3b17ee 100644 --- a/common/lib/xmodule/xmodule/xml_module.py +++ b/common/lib/xmodule/xmodule/xml_module.py @@ -6,8 +6,7 @@ import sys from lxml import etree from xblock.fields import Dict, Scope, ScopeIds -from xmodule.x_module import XModuleDescriptor, policy_key -from xmodule.modulestore import Location +from xmodule.x_module import XModuleDescriptor from xmodule.modulestore.inheritance import own_metadata, InheritanceKeyValueStore from xmodule.modulestore.xml_exporter import EdxJSONEncoder from xblock.runtime import KvsFieldData @@ -176,7 +175,7 @@ class XmlDescriptor(XModuleDescriptor): return etree.parse(file_object, parser=edx_xml_parser).getroot() @classmethod - def load_file(cls, filepath, fs, def_id): + def load_file(cls, filepath, fs, def_id): # pylint: disable=invalid-name ''' Open the specified file in fs, and call cls.file_to_xml on it, returning the lxml object. @@ -184,8 +183,8 @@ class XmlDescriptor(XModuleDescriptor): Add details and reraise on error. ''' try: - with fs.open(filepath) as file: - return cls.file_to_xml(file) + with fs.open(filepath) as xml_file: + return cls.file_to_xml(xml_file) except Exception as err: # Add info about where we are, but keep the traceback msg = 'Unable to load file contents at path %s for item %s: %s ' % ( diff --git a/common/static/coffee/spec/discussion/view/discussion_thread_view_spec.coffee b/common/static/coffee/spec/discussion/view/discussion_thread_view_spec.coffee new file mode 100644 index 0000000000..bd251de39f --- /dev/null +++ b/common/static/coffee/spec/discussion/view/discussion_thread_view_spec.coffee @@ -0,0 +1,88 @@ +describe "DiscussionThreadView", -> + beforeEach -> + setFixtures( + """ + +
+ """ + ) + + jasmine.Clock.useMock() + @threadData = { + id: "dummy" + } + @thread = new Thread(@threadData) + @view = new DiscussionThreadView({ model: @thread }) + @view.setElement($(".thread-fixture")) + spyOn($, "ajax") + # Avoid unnecessary boilerplate + spyOn(@view.showView, "render") + spyOn(@view, "makeWmdEditor") + spyOn(DiscussionThreadView.prototype, "renderResponse") + + describe "response count and pagination", -> + + setNextResponseContent = (content) -> + $.ajax.andCallFake( + (params) => + params.success({"content": content}) + {always: ->} + ) + + renderWithContent = (view, content) -> + setNextResponseContent(content) + view.render() + jasmine.Clock.tick(100) + + assertRenderedCorrectly = (view, countText, displayCountText, buttonText) -> + expect(view.$el.find(".response-count").text()).toEqual(countText) + if displayCountText + expect(view.$el.find(".response-display-count").text()).toEqual(displayCountText) + else + expect(view.$el.find(".response-display-count").length).toEqual(0) + if buttonText + expect(view.$el.find(".load-response-button").text()).toEqual(buttonText) + else + expect(view.$el.find(".load-response-button").length).toEqual(0) + + it "correctly render for a thread with no responses", -> + renderWithContent(@view, {resp_total: 0, children: []}) + assertRenderedCorrectly(@view, "0 responses", null, null) + + it "correctly render for a thread with one response", -> + renderWithContent(@view, {resp_total: 1, children: [{}]}) + assertRenderedCorrectly(@view, "1 response", "Showing all responses", null) + + it "correctly render for a thread with one additional page", -> + renderWithContent(@view, {resp_total: 2, children: [{}]}) + assertRenderedCorrectly(@view, "2 responses", "Showing first response", "Load all responses") + + it "correctly render for a thread with multiple additional pages", -> + renderWithContent(@view, {resp_total: 111, children: [{}, {}]}) + assertRenderedCorrectly(@view, "111 responses", "Showing first 2 responses", "Load next 100 responses") + + describe "on clicking the load more button", -> + beforeEach -> + renderWithContent(@view, {resp_total: 5, children: [{}]}) + assertRenderedCorrectly(@view, "5 responses", "Showing first response", "Load all responses") + + it "correctly re-render when all threads have loaded", -> + setNextResponseContent({resp_total: 5, children: [{}, {}, {}, {}]}) + @view.$el.find(".load-response-button").click() + assertRenderedCorrectly(@view, "5 responses", "Showing all responses", null) + + it "correctly re-render when one page remains", -> + setNextResponseContent({resp_total: 42, children: [{}, {}]}) + @view.$el.find(".load-response-button").click() + assertRenderedCorrectly(@view, "42 responses", "Showing first 3 responses", "Load all responses") + + it "correctly re-render when multiple pages remain", -> + setNextResponseContent({resp_total: 111, children: [{}, {}]}) + @view.$el.find(".load-response-button").click() + assertRenderedCorrectly(@view, "111 responses", "Showing first 3 responses", "Load next 100 responses") diff --git a/common/static/coffee/src/discussion/content.coffee b/common/static/coffee/src/discussion/content.coffee index 5e3d4ce20b..7090063f6a 100644 --- a/common/static/coffee/src/discussion/content.coffee +++ b/common/static/coffee/src/discussion/content.coffee @@ -159,6 +159,9 @@ if Backbone? created_at_time: -> new Date(@get("created_at")).getTime() + hasResponses: -> + @get('comments_count') > 0 + class @Comment extends @Content urlMappers: 'reply': -> DiscussionUtil.urlFor('create_sub_comment', @id) diff --git a/common/static/coffee/src/discussion/views/discussion_thread_list_view.coffee b/common/static/coffee/src/discussion/views/discussion_thread_list_view.coffee index 2dd18717d5..7fa48a9bdd 100644 --- a/common/static/coffee/src/discussion/views/discussion_thread_list_view.coffee +++ b/common/static/coffee/src/discussion/views/discussion_thread_list_view.coffee @@ -174,12 +174,14 @@ if Backbone? content.addClass("resolved") if thread.get('read') content.addClass("read") - if thread.get('unread_comments_count') > 0 + unreadCount = thread.get('unread_comments_count') + if unreadCount > 0 content.find('.comments-count').addClass("unread").attr( "data-tooltip", interpolate( - ngettext('%(unread_count)s new comment', '%(unread_count)s new comments', {'unread_count': thread.get('unread_comments_count')}), - [thread.get('unread_comments_count')] + ngettext('%(unread_count)s new comment', '%(unread_count)s new comments', unreadCount), + {unread_count: thread.get('unread_comments_count')}, + true ) ) @highlight(content) diff --git a/common/static/coffee/src/discussion/views/discussion_thread_view.coffee b/common/static/coffee/src/discussion/views/discussion_thread_view.coffee index f43f97f204..57297a1a1e 100644 --- a/common/static/coffee/src/discussion/views/discussion_thread_view.coffee +++ b/common/static/coffee/src/discussion/views/discussion_thread_view.coffee @@ -1,8 +1,12 @@ if Backbone? class @DiscussionThreadView extends DiscussionContentView + INITIAL_RESPONSE_PAGE_SIZE = 25 + SUBSEQUENT_RESPONSE_PAGE_SIZE = 100 + events: "click .discussion-submit-post": "submitComment" + "click .add-response-btn": "scrollToAddResponse" $: (selector) -> @$el.find(selector) @@ -10,6 +14,7 @@ if Backbone? initialize: -> super() @createShowView() + @responses = new Comments() renderTemplate: -> @template = _.template($("#thread-template").html()) @@ -17,7 +22,6 @@ if Backbone? render: -> @$el.html(@renderTemplate()) - @$el.find(".loading").hide() @delegateEvents() @renderShowView() @@ -25,26 +29,96 @@ if Backbone? @$("span.timeago").timeago() @makeWmdEditor "reply-body" - @renderResponses() + @renderAddResponseButton() + @responses.on("add", @renderResponse) + # Without a delay, jQuery doesn't add the loading extension defined in + # utils.coffee before safeAjax is invoked, which results in an error + setTimeout( + => @loadResponses(INITIAL_RESPONSE_PAGE_SIZE, @$el.find(".responses"), true), + 100 + ) @ cleanup: -> if @responsesRequest? @responsesRequest.abort() - renderResponses: -> - setTimeout(=> - @$el.find(".loading").show() - , 200) + loadResponses: (responseLimit, elem, firstLoad) -> @responsesRequest = DiscussionUtil.safeAjax url: DiscussionUtil.urlFor('retrieve_single_thread', @model.get('commentable_id'), @model.id) + data: + resp_skip: @responses.size() + resp_limit: responseLimit if responseLimit + $elem: elem + $loading: elem + takeFocus: true + complete: => + @responseRequest = null success: (data, textStatus, xhr) => - @responsesRequest = null - @$el.find(".loading").remove() Content.loadContentInfos(data['annotated_content_info']) - comments = new Comments(data['content']['children']) - comments.each @renderResponse + @responses.add(data['content']['children']) + @renderResponseCountAndPagination(data['content']['resp_total']) @trigger "thread:responses:rendered" + error: => + if firstLoad + DiscussionUtil.discussionAlert( + gettext("Sorry"), + gettext("We had some trouble loading responses. Please reload the page.") + ) + else + DiscussionUtil.discussionAlert( + gettext("Sorry"), + gettext("We had some trouble loading more responses. Please try again.") + ) + + renderResponseCountAndPagination: (responseTotal) => + @$el.find(".response-count").html( + interpolate( + ngettext( + "%(numResponses)s response", + "%(numResponses)s responses", + responseTotal + ), + {numResponses: responseTotal}, + true + ) + ) + responsePagination = @$el.find(".response-pagination") + responsePagination.empty() + if responseTotal > 0 + responsesRemaining = responseTotal - @responses.size() + showingResponsesText = + if responsesRemaining == 0 + gettext("Showing all responses") + else + interpolate( + ngettext( + "Showing first response", + "Showing first %(numResponses)s responses", + @responses.size() + ), + {numResponses: @responses.size()}, + true + ) + responsePagination.append($("").addClass("response-display-count").html( + _.escape(showingResponsesText) + )) + if responsesRemaining > 0 + if responsesRemaining < SUBSEQUENT_RESPONSE_PAGE_SIZE + responseLimit = null + buttonText = gettext("Load all responses") + else + responseLimit = SUBSEQUENT_RESPONSE_PAGE_SIZE + buttonText = interpolate( + gettext("Load next %(numResponses)s responses"), + {numResponses: responseLimit}, + true + ) + loadMoreButton = $("
diff --git a/lms/djangoapps/courseware/mock_lti_server/test_mock_lti_server.py b/lms/djangoapps/courseware/mock_lti_server/test_mock_lti_server.py index d6f3c5df3c..c60e095c15 100644 --- a/lms/djangoapps/courseware/mock_lti_server/test_mock_lti_server.py +++ b/lms/djangoapps/courseware/mock_lti_server/test_mock_lti_server.py @@ -57,7 +57,7 @@ class MockLTIServerTest(unittest.TestCase): #wrong number of params and no signature payload = { 'user_id': 'default_user_id', - 'role': 'student', + 'roles': 'Student', 'oauth_nonce': '', 'oauth_timestamp': '', } @@ -73,7 +73,7 @@ class MockLTIServerTest(unittest.TestCase): """ payload = { 'user_id': 'default_user_id', - 'role': 'student', + 'roles': 'Student', 'oauth_nonce': '', 'oauth_timestamp': '', 'oauth_consumer_key': 'test_client_key', @@ -100,7 +100,7 @@ class MockLTIServerTest(unittest.TestCase): """ payload = { 'user_id': 'default_user_id', - 'role': 'student', + 'roles': 'Student', 'oauth_nonce': '', 'oauth_timestamp': '', 'oauth_consumer_key': 'test_client_key', @@ -126,7 +126,7 @@ class MockLTIServerTest(unittest.TestCase): payload = { 'user_id': 'default_user_id', - 'role': 'student', + 'roles': 'Student', 'oauth_nonce': '', 'oauth_timestamp': '', 'oauth_consumer_key': 'test_client_key', diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index f68c6cc316..207e46f638 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -17,7 +17,7 @@ import django.utils from django.views.decorators.csrf import csrf_exempt from capa.xqueue_interface import XQueueInterface -from courseware.access import has_access +from courseware.access import has_access, get_user_role from courseware.masquerade import setup_masquerade from courseware.model_data import FieldDataCache, DjangoKeyValueStore from lms.lib.xblock.field_data import LmsFieldData @@ -291,7 +291,7 @@ def get_module_for_descriptor_internal(user, descriptor, field_data_cache, cours position, wrap_xmodule_display, grade_bucket_type, static_asset_path) - def publish(event, custom_user=None): + def publish(block, event, custom_user=None): """A function that allows XModules to publish events. This only supports grade changes right now.""" if event.get('event_name') != 'grade': return @@ -321,10 +321,10 @@ def get_module_for_descriptor_internal(user, descriptor, field_data_cache, cours org, course_num, run = course_id.split("/") tags = [ - "org:{0}".format(org), - "course:{0}".format(course_num), - "run:{0}".format(run), - "score_bucket:{0}".format(score_bucket) + u"org:{0}".format(org), + u"course:{0}".format(course_num), + u"run:{0}".format(run), + u"score_bucket:{0}".format(score_bucket) ] if grade_bucket_type is not None: @@ -432,6 +432,7 @@ def get_module_for_descriptor_internal(user, descriptor, field_data_cache, cours # directly as the runtime i18n service. 'i18n': django.utils.translation, }, + get_user_role=lambda: get_user_role(user, course_id), ) # pass position specified in URL to module through ModuleSystem @@ -442,7 +443,7 @@ def get_module_for_descriptor_internal(user, descriptor, field_data_cache, cours make_psychometrics_data_update_handler(course_id, user, descriptor.location.url()) ) - system.set('user_is_staff', has_access(user, descriptor.location, 'staff', course_id)) + system.set(u'user_is_staff', has_access(user, descriptor.location, u'staff', course_id)) # make an ErrorDescriptor -- assuming that the descriptor's system is ok if has_access(user, descriptor.location, 'staff', course_id): diff --git a/lms/djangoapps/courseware/tests/test_access.py b/lms/djangoapps/courseware/tests/test_access.py index 3b24d5d4c7..29ef87b22d 100644 --- a/lms/djangoapps/courseware/tests/test_access.py +++ b/lms/djangoapps/courseware/tests/test_access.py @@ -102,3 +102,48 @@ class AccessTestCase(TestCase): def test__user_passed_as_none(self): """Ensure has_access handles a user being passed as null""" access.has_access(None, 'global', 'staff', None) + +class UserRoleTestCase(TestCase): + """ + Tests for user roles. + """ + def setUp(self): + self.course = Location('i4x://edX/toy/course/2012_Fall') + self.anonymous_user = AnonymousUserFactory() + self.student = UserFactory() + self.global_staff = UserFactory(is_staff=True) + self.course_staff = StaffFactory(course=self.course) + self.course_instructor = InstructorFactory(course=self.course) + + def test_user_role_staff(self): + """Ensure that user role is student for staff masqueraded as student.""" + self.assertEqual( + 'staff', + access.get_user_role(self.course_staff, self.course.course_id) + ) + # Masquerade staff + self.course_staff.masquerade_as_student = True + self.assertEqual( + 'student', + access.get_user_role(self.course_staff, self.course.course_id) + ) + + def test_user_role_instructor(self): + """Ensure that user role is student for instructor masqueraded as student.""" + self.assertEqual( + 'instructor', + access.get_user_role(self.course_instructor, self.course.course_id) + ) + # Masquerade instructor + self.course_instructor.masquerade_as_student = True + self.assertEqual( + 'student', + access.get_user_role(self.course_instructor, self.course.course_id) + ) + + def test_user_role_anonymous(self): + """Ensure that user role is student for anonymous user.""" + self.assertEqual( + 'student', + access.get_user_role(self.anonymous_user, self.course.course_id) + ) diff --git a/lms/djangoapps/courseware/tests/test_i18n.py b/lms/djangoapps/courseware/tests/test_i18n.py new file mode 100644 index 0000000000..22331c4399 --- /dev/null +++ b/lms/djangoapps/courseware/tests/test_i18n.py @@ -0,0 +1,25 @@ +""" +Tests i18n in courseware +""" +from django.test import TestCase +from django.test.utils import override_settings +from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE +import re + + +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE, LANGUAGES=(('eo', 'Esperanto'),)) +class I18nTestCase(TestCase): + """ + Tests for i18n + """ + def test_default_is_en(self): + response = self.client.get('/') + self.assertIn('', response.content) + self.assertEqual(response['Content-Language'], 'en') + self.assertTrue(re.search('', response.content)) + + def test_esperanto(self): + response = self.client.get('/', HTTP_ACCEPT_LANGUAGE='eo') + self.assertIn('', response.content) + self.assertEqual(response['Content-Language'], 'eo') + self.assertTrue(re.search('', response.content)) diff --git a/lms/djangoapps/courseware/tests/test_lti_integration.py b/lms/djangoapps/courseware/tests/test_lti_integration.py index 178cef57f1..eb04684e7f 100644 --- a/lms/djangoapps/courseware/tests/test_lti_integration.py +++ b/lms/djangoapps/courseware/tests/test_lti_integration.py @@ -43,7 +43,7 @@ class TestLTI(BaseTestXmodule): u'launch_presentation_return_url': '', u'lti_message_type': u'basic-lti-launch-request', u'lti_version': 'LTI-1p0', - u'role': u'student', + u'roles': u'Student', u'resource_link_id': module_id, u'lis_result_sourcedid': sourcedId, diff --git a/lms/djangoapps/courseware/tests/test_microsites.py b/lms/djangoapps/courseware/tests/test_microsites.py index e9cd776376..b8d3a1659e 100644 --- a/lms/djangoapps/courseware/tests/test_microsites.py +++ b/lms/djangoapps/courseware/tests/test_microsites.py @@ -72,7 +72,7 @@ class TestMicrosites(ModuleStoreTestCase, LoginEnrollmentTestCase): self.assertContains(resp, 'This is a Test Microsite Overlay') # Overlay test message self.assertContains(resp, 'test_microsite/images/header-logo.png') # logo swap self.assertContains(resp, 'test_microsite/css/test_microsite.css') # css override - self.assertContains(resp, 'Test Microsite') # page title + self.assertContains(resp, 'Test Microsite') # page title # assert that test course display name is visible self.assertContains(resp, 'Robot_Super_Course') @@ -114,12 +114,6 @@ class TestMicrosites(ModuleStoreTestCase, LoginEnrollmentTestCase): # assert that footer template has been properly overriden on homepage self.assertNotContains(resp, 'This is a Test Microsite footer') - # assert that the edX partners section is not in the HTML - self.assertContains(resp, '
') - - # assert that the edX partners tag line is not in the HTML - self.assertContains(resp, 'Explore free courses from') - def test_microsite_course_enrollment(self): """ diff --git a/lms/djangoapps/courseware/tests/test_registration_extra_vars.py b/lms/djangoapps/courseware/tests/test_registration_extra_vars.py new file mode 100644 index 0000000000..af8c8361a5 --- /dev/null +++ b/lms/djangoapps/courseware/tests/test_registration_extra_vars.py @@ -0,0 +1,228 @@ +# -*- coding: utf-8 +""" +Tests for extra registration variables +""" +import json +import uuid + +from django.conf import settings +from django.test import TestCase +from django.core.urlresolvers import reverse +from mock import patch + + +class TestExtraRegistrationVariables(TestCase): + """ + Test that extra registration variables are properly checked according to settings + """ + def setUp(self): + super(TestExtraRegistrationVariables, self).setUp() + self.url = reverse('create_account') + username = 'foo_bar' + uuid.uuid4().hex + self.url_params = { + 'username': username, + 'name': username, + 'email': 'foo' + uuid.uuid4().hex + '@bar.com', + 'password': 'password', + 'terms_of_service': 'true', + 'honor_code': 'true', + } + + def test_default_missing_honor(self): + """ + By default, the honor code must be required + """ + self.url_params['honor_code'] = '' + response = self.client.post(self.url, self.url_params) + self.assertEqual(response.status_code, 400) + obj = json.loads(response.content) + self.assertEqual( + obj['value'], + u'To enroll, you must follow the honor code.', + ) + + @patch.dict(settings.REGISTRATION_EXTRA_FIELDS, {'honor_code': 'optional'}) + def test_optional_honor(self): + """ + With the honor code is made optional, should pass without extra vars + """ + self.url_params['honor_code'] = '' + response = self.client.post(self.url, self.url_params) + self.assertEqual(response.status_code, 200) + obj = json.loads(response.content) + self.assertEqual(obj['success'], True) + + @patch.dict(settings.REGISTRATION_EXTRA_FIELDS, { + 'level_of_education': 'hidden', + 'gender': 'hidden', + 'year_of_birth': 'hidden', + 'mailing_address': 'hidden', + 'goals': 'hidden', + 'honor_code': 'hidden', + 'city': 'hidden', + 'country': 'hidden'}) + def test_all_hidden(self): + """ + When the fields are all hidden, should pass without extra vars + """ + 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(settings.REGISTRATION_EXTRA_FIELDS, {'city': 'required'}) + def test_required_city_missing(self): + """ + Should require the city if configured as 'required' but missing + """ + self.url_params['city'] = '' + response = self.client.post(self.url, self.url_params) + self.assertEqual(response.status_code, 400) + obj = json.loads(response.content) + self.assertEqual( + obj['value'], + u'A city is required', + ) + + @patch.dict(settings.REGISTRATION_EXTRA_FIELDS, {'city': 'required'}) + def test_required_city(self): + """ + Should require the city if configured as 'required' but missing + """ + self.url_params['city'] = 'New York' + 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(settings.REGISTRATION_EXTRA_FIELDS, {'country': 'required'}) + def test_required_country_missing(self): + """ + Should require the country if configured as 'required' but missing + """ + self.url_params['country'] = '' + response = self.client.post(self.url, self.url_params) + self.assertEqual(response.status_code, 400) + obj = json.loads(response.content) + self.assertEqual( + obj['value'], + u'A country is required', + ) + + @patch.dict(settings.REGISTRATION_EXTRA_FIELDS, {'country': 'required'}) + def test_required_country(self): + self.url_params['country'] = 'New York' + 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(settings.REGISTRATION_EXTRA_FIELDS, {'level_of_education': 'required'}) + def test_required_level_of_education_missing(self): + """ + Should require the level_of_education if configured as 'required' but missing + """ + self.url_params['level_of_education'] = '' + response = self.client.post(self.url, self.url_params) + self.assertEqual(response.status_code, 400) + obj = json.loads(response.content) + self.assertEqual( + obj['value'], + u'A level of education is required', + ) + + @patch.dict(settings.REGISTRATION_EXTRA_FIELDS, {'level_of_education': 'required'}) + def test_required_level_of_education(self): + self.url_params['level_of_education'] = 'p' + 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(settings.REGISTRATION_EXTRA_FIELDS, {'gender': 'required'}) + def test_required_gender_missing(self): + """ + Should require the gender if configured as 'required' but missing + """ + self.url_params['gender'] = '' + response = self.client.post(self.url, self.url_params) + self.assertEqual(response.status_code, 400) + obj = json.loads(response.content) + self.assertEqual( + obj['value'], + u'Your gender is required', + ) + + @patch.dict(settings.REGISTRATION_EXTRA_FIELDS, {'gender': 'required'}) + def test_required_gender(self): + self.url_params['gender'] = 'm' + 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(settings.REGISTRATION_EXTRA_FIELDS, {'year_of_birth': 'required'}) + def test_required_year_of_birth_missing(self): + """ + Should require the year_of_birth if configured as 'required' but missing + """ + self.url_params['year_of_birth'] = '' + response = self.client.post(self.url, self.url_params) + self.assertEqual(response.status_code, 400) + obj = json.loads(response.content) + self.assertEqual( + obj['value'], + u'Your year of birth is required', + ) + + @patch.dict(settings.REGISTRATION_EXTRA_FIELDS, {'year_of_birth': 'required'}) + def test_required_year_of_birth(self): + self.url_params['year_of_birth'] = '1982' + 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(settings.REGISTRATION_EXTRA_FIELDS, {'mailing_address': 'required'}) + def test_required_mailing_address_missing(self): + """ + Should require the mailing_address if configured as 'required' but missing + """ + self.url_params['mailing_address'] = '' + response = self.client.post(self.url, self.url_params) + self.assertEqual(response.status_code, 400) + obj = json.loads(response.content) + self.assertEqual( + obj['value'], + u'Your mailing address is required', + ) + + @patch.dict(settings.REGISTRATION_EXTRA_FIELDS, {'mailing_address': 'required'}) + def test_required_mailing_address(self): + self.url_params['mailing_address'] = 'my address' + 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(settings.REGISTRATION_EXTRA_FIELDS, {'goals': 'required'}) + def test_required_goals_missing(self): + """ + Should require the goals if configured as 'required' but missing + """ + self.url_params['goals'] = '' + response = self.client.post(self.url, self.url_params) + self.assertEqual(response.status_code, 400) + obj = json.loads(response.content) + self.assertEqual( + obj['value'], + u'A description of your goals is required', + ) + + @patch.dict(settings.REGISTRATION_EXTRA_FIELDS, {'goals': 'required'}) + def test_required_goals(self): + self.url_params['goals'] = 'my goals' + response = self.client.post(self.url, self.url_params) + self.assertEqual(response.status_code, 200) + obj = json.loads(response.content) + self.assertTrue(obj['success']) diff --git a/lms/djangoapps/courseware/tests/test_video_mongo.py b/lms/djangoapps/courseware/tests/test_video_mongo.py index 21f9835c5c..e6a24154bd 100644 --- a/lms/djangoapps/courseware/tests/test_video_mongo.py +++ b/lms/djangoapps/courseware/tests/test_video_mongo.py @@ -66,7 +66,8 @@ class TestVideoYouTube(TestVideo): 'end': 3610.0, 'id': self.item_module.location.html_id(), 'sources': sources, - 'speed': 1.0, + 'speed': 'null', + 'general_speed': 1.0, 'start': 3603.0, 'sub': u'a_sub_file.srt.sjson', 'track': None, @@ -120,7 +121,8 @@ class TestVideoNonYouTube(TestVideo): 'end': 3610.0, 'id': self.item_module.location.html_id(), 'sources': sources, - 'speed': 1.0, + 'speed': 'null', + 'general_speed': 1.0, 'start': 3603.0, 'sub': u'a_sub_file.srt.sjson', 'track': None, @@ -201,7 +203,8 @@ class TestGetHtmlMethod(BaseTestXmodule): }, 'start': 3603.0, 'sub': u'a_sub_file.srt.sjson', - 'speed': 1.0, + 'speed': 'null', + 'general_speed': 1.0, 'track': None, 'youtube_streams': '1.00:OEoXaMPEzfM', 'autoplay': settings.FEATURES.get('AUTOPLAY_VIDEOS', True), @@ -302,7 +305,8 @@ class TestGetHtmlMethod(BaseTestXmodule): 'end': 3610.0, 'id': None, 'sources': None, - 'speed': 1.0, + 'speed': 'null', + 'general_speed': 1.0, 'start': 3603.0, 'sub': u'a_sub_file.srt.sjson', 'track': None, diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index e60b6c5394..d87a3830e2 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -2,6 +2,7 @@ import logging import urllib from functools import partial +from collections import defaultdict from django.conf import settings from django.core.context_processors import csrf @@ -29,6 +30,7 @@ from courseware.models import StudentModule, StudentModuleHistory from course_modes.models import CourseMode from student.models import UserTestGroup, CourseEnrollment +from student.views import course_from_id, single_course_reverification_info from util.cache import cache, cache_if_anonymous from xblock.fragment import Fragment from xmodule.modulestore import Location @@ -238,7 +240,7 @@ def index(request, course_id, chapter=None, section=None, registered = registered_for_course(course, user) if not registered: # TODO (vshnayder): do course instructors need to be registered to see course? - log.debug('User %s tried to view course %s but is not enrolled' % (user, course.location.url())) + log.debug(u'User {0} tried to view course {1} but is not enrolled'.format(user, course.location.url())) return redirect(reverse('about_course', args=[course.id])) masq = setup_masquerade(request, staff_access) @@ -249,8 +251,8 @@ def index(request, course_id, chapter=None, section=None, course_module = get_module_for_descriptor(user, request, course, field_data_cache, course.id) if course_module is None: - log.warning('If you see this, something went wrong: if we got this' - ' far, should have gotten a course module for this user') + log.warning(u'If you see this, something went wrong: if we got this' + u' far, should have gotten a course module for this user') return redirect(reverse('about_course', args=[course.id])) if chapter is None: @@ -265,7 +267,8 @@ def index(request, course_id, chapter=None, section=None, 'fragment': Fragment(), 'staff_access': staff_access, 'masquerade': masq, - 'xqa_server': settings.FEATURES.get('USE_XQA_SERVER', 'http://xqa:server@content-qa.mitx.mit.edu/xqa') + 'xqa_server': settings.FEATURES.get('USE_XQA_SERVER', 'http://xqa:server@content-qa.mitx.mit.edu/xqa'), + 'reverifications': fetch_reverify_banner_info(request, course_id), } # Only show the chat if it's enabled by the course and in the @@ -328,7 +331,7 @@ def index(request, course_id, chapter=None, section=None, # Save where we are in the chapter save_child_position(chapter_module, section) context['fragment'] = section_module.render('student_view') - + context['section_title'] = section_descriptor.display_name_with_default else: # section is none, so display a message prev_section = get_current_child(chapter_module) @@ -424,9 +427,9 @@ def jump_to(request, course_id, location): try: (course_id, chapter, section, position) = path_to_location(modulestore(), course_id, location) except ItemNotFoundError: - raise Http404("No data at this location: {0}".format(location)) + raise Http404(u"No data at this location: {0}".format(location)) except NoPathToItem: - raise Http404("This location is not in any class: {0}".format(location)) + raise Http404(u"This location is not in any class: {0}".format(location)) # choose the appropriate view (and provide the necessary args) based on the # args provided by the redirect. @@ -451,9 +454,19 @@ def course_info(request, course_id): course = get_course_with_access(request.user, course_id, 'load') staff_access = has_access(request.user, course, 'staff') masq = setup_masquerade(request, staff_access) # allow staff to toggle masquerade on info page + reverifications = fetch_reverify_banner_info(request, course_id) - return render_to_response('courseware/info.html', {'request': request, 'course_id': course_id, 'cache': None, - 'course': course, 'staff_access': staff_access, 'masquerade': masq}) + context = { + 'request': request, + 'course_id': course_id, + 'cache': None, + 'course': course, + 'staff_access': staff_access, + 'masquerade': masq, + 'reverifications': reverifications, + } + + return render_to_response('courseware/info.html', context) @ensure_csrf_cookie @@ -654,6 +667,7 @@ def _progress(request, course_id, student_id): 'grade_summary': grade_summary, 'staff_access': staff_access, 'student': student, + 'reverifications': fetch_reverify_banner_info(request, course_id) } with grades.manual_transaction(): @@ -662,6 +676,21 @@ def _progress(request, course_id, student_id): return response +def fetch_reverify_banner_info(request, course_id): + """ + Fetches needed context variable to display reverification banner in courseware + """ + reverifications = defaultdict(list) + user = request.user + if not user.id: + return reverifications + enrollment = CourseEnrollment.get_or_create_enrollment(request.user, course_id) + course = course_from_id(course_id) + info = single_course_reverification_info(user, course, enrollment) + if info: + reverifications[info.status].append(info) + return reverifications + @login_required def submission_history(request, course_id, student_username, location): """Render an HTML fragment (meant for inclusion elsewhere) that renders a diff --git a/lms/djangoapps/dashboard/sysadmin.py b/lms/djangoapps/dashboard/sysadmin.py index a8dd51f54c..87823947a9 100644 --- a/lms/djangoapps/dashboard/sysadmin.py +++ b/lms/djangoapps/dashboard/sysadmin.py @@ -47,7 +47,6 @@ from xmodule.modulestore.xml import XMLModuleStore log = logging.getLogger(__name__) - class SysadminDashboardView(TemplateView): """Base class for sysadmin dashboard views with common methods""" @@ -675,7 +674,7 @@ class GitLogs(TemplateView): mdb = mongoengine.connect(mongo_db['db'], host=mongo_db['host']) except mongoengine.connection.ConnectionError: log.exception('Unable to connect to mongodb to save log, ' - 'please check MONGODB_LOG settings.') + 'please check MONGODB_LOG settings.') if course_id is None: # Require staff if not going to specific course diff --git a/lms/djangoapps/django_comment_client/forum/tests.py b/lms/djangoapps/django_comment_client/forum/tests.py index fa3d9c96b3..4425c3e23b 100644 --- a/lms/djangoapps/django_comment_client/forum/tests.py +++ b/lms/djangoapps/django_comment_client/forum/tests.py @@ -11,7 +11,7 @@ from django_comment_client.forum import views from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE from nose.tools import assert_true # pylint: disable=E0611 -from mock import patch, Mock +from mock import patch, Mock, ANY import logging @@ -85,6 +85,26 @@ class ViewsExceptionTestCase(UrlResetMixin, ModuleStoreTestCase): self.assertEqual(self.response.status_code, 404) +def make_mock_thread_data(text, thread_id, include_children): + thread_data = { + "id": thread_id, + "type": "thread", + "title": text, + "body": text, + "commentable_id": "dummy_commentable_id", + "resp_total": 42, + "resp_skip": 25, + "resp_limit": 5, + } + if include_children: + thread_data["children"] = [{ + "id": "dummy_comment_id", + "type": "comment", + "body": text, + }] + return thread_data + + def make_mock_request_impl(text, thread_id=None): def mock_request_impl(*args, **kwargs): url = args[1] @@ -92,30 +112,13 @@ def make_mock_request_impl(text, thread_id=None): return Mock( status_code=200, text=json.dumps({ - "collection": [{ - "id": "dummy_thread_id", - "type": "thread", - "commentable_id": "dummy_commentable_id", - "title": text, - "body": text, - }] + "collection": [make_mock_thread_data(text, "dummy_thread_id", False)] }) ) elif thread_id and url.endswith(thread_id): return Mock( status_code=200, - text=json.dumps({ - "id": thread_id, - "type": "thread", - "title": text, - "body": text, - "commentable_id": "dummy_commentable_id", - "children": [{ - "id": "dummy_comment_id", - "type": "comment", - "body": text, - }], - }) + text=json.dumps(make_mock_thread_data(text, thread_id, True)) ) else: # user query return Mock( @@ -129,6 +132,116 @@ def make_mock_request_impl(text, thread_id=None): return mock_request_impl +class StringEndsWithMatcher(object): + def __init__(self, suffix): + self.suffix = suffix + + def __eq__(self, other): + return other.endswith(self.suffix) + + +class PartialDictMatcher(object): + def __init__(self, expected_values): + self.expected_values = expected_values + + def __eq__(self, other): + return all([ + key in other and other[key] == value + for key, value in self.expected_values.iteritems() + ]) + + +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) +@patch('requests.request') +class SingleThreadTestCase(ModuleStoreTestCase): + def setUp(self): + self.course = CourseFactory.create() + self.student = UserFactory.create() + CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id) + + def test_ajax(self, mock_request): + text = "dummy content" + thread_id = "test_thread_id" + mock_request.side_effect = make_mock_request_impl(text, thread_id) + + request = RequestFactory().get( + "dummy_url", + HTTP_X_REQUESTED_WITH="XMLHttpRequest" + ) + request.user = self.student + response = views.single_thread( + request, + self.course.id, + "dummy_discussion_id", + "test_thread_id" + ) + + self.assertEquals(response.status_code, 200) + response_data = json.loads(response.content) + self.assertEquals( + response_data["content"], + make_mock_thread_data(text, thread_id, True) + ) + mock_request.assert_called_with( + "get", + StringEndsWithMatcher(thread_id), # url + data=None, + params=PartialDictMatcher({"mark_as_read": True, "user_id": 1, "recursive": True}), + headers=ANY, + timeout=ANY + ) + + def test_skip_limit(self, mock_request): + text = "dummy content" + thread_id = "test_thread_id" + response_skip = "45" + response_limit = "15" + mock_request.side_effect = make_mock_request_impl(text, thread_id) + + request = RequestFactory().get( + "dummy_url", + {"resp_skip": response_skip, "resp_limit": response_limit}, + HTTP_X_REQUESTED_WITH="XMLHttpRequest" + ) + request.user = self.student + response = views.single_thread( + request, + self.course.id, + "dummy_discussion_id", + "test_thread_id" + ) + self.assertEquals(response.status_code, 200) + response_data = json.loads(response.content) + self.assertEquals( + response_data["content"], + make_mock_thread_data(text, thread_id, True) + ) + mock_request.assert_called_with( + "get", + StringEndsWithMatcher(thread_id), # url + data=None, + params=PartialDictMatcher({ + "mark_as_read": True, + "user_id": 1, + "recursive": True, + "resp_skip": response_skip, + "resp_limit": response_limit, + }), + headers=ANY, + timeout=ANY + ) + + def test_post(self, mock_request): + request = RequestFactory().post("dummy_url") + response = views.single_thread( + request, + self.course.id, + "dummy_discussion_id", + "dummy_thread_id" + ) + self.assertEquals(response.status_code, 405) + + @override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) class InlineDiscussionUnicodeTestCase(ModuleStoreTestCase, UnicodeTestMixin): def setUp(self): diff --git a/lms/djangoapps/django_comment_client/forum/views.py b/lms/djangoapps/django_comment_client/forum/views.py index d6a84566e2..c328b0e141 100644 --- a/lms/djangoapps/django_comment_client/forum/views.py +++ b/lms/djangoapps/django_comment_client/forum/views.py @@ -6,6 +6,7 @@ from django.contrib.auth.decorators import login_required from django.http import Http404 from django.core.context_processors import csrf from django.contrib.auth.models import User +from django.views.decorators.http import require_GET import newrelic.agent from edxmako.shortcuts import render_to_response @@ -229,6 +230,7 @@ def forum_form_discussion(request, course_id): return render_to_response('discussion/index.html', context) +@require_GET @login_required def single_thread(request, course_id, discussion_id, thread_id): nr_transaction = newrelic.agent.current_transaction() @@ -237,12 +239,16 @@ def single_thread(request, course_id, discussion_id, thread_id): cc_user = cc.User.from_django_user(request.user) user_info = cc_user.to_dict() - thread = cc.Thread.find(thread_id).retrieve(recursive=True, user_id=request.user.id) + thread = cc.Thread.find(thread_id).retrieve( + recursive=True, + user_id=request.user.id, + response_skip=request.GET.get("resp_skip"), + response_limit=request.GET.get("resp_limit") + ) if request.is_ajax(): with newrelic.agent.FunctionTrace(nr_transaction, "get_annotated_content_infos"): annotated_content_info = utils.get_annotated_content_infos(course_id, thread, request.user, user_info=user_info) - context = {'thread': thread.to_dict(), 'course_id': course_id} content = utils.safe_content(thread.to_dict()) with newrelic.agent.FunctionTrace(nr_transaction, "add_courseware_context"): add_courseware_context([content], course) diff --git a/lms/djangoapps/django_comment_client/management/commands/reload_forum_users.py b/lms/djangoapps/django_comment_client/management/commands/reload_forum_users.py index 81154da69a..53c6aaf2b2 100644 --- a/lms/djangoapps/django_comment_client/management/commands/reload_forum_users.py +++ b/lms/djangoapps/django_comment_client/management/commands/reload_forum_users.py @@ -16,7 +16,7 @@ class Command(BaseCommand): cc_user = cc.User.from_django_user(user) cc_user.save() except Exception as err: - print "update user info to discussion failed for user with id: %s" % user + print "update user info to discussion failed for user with id: %s, error=%s" % (user, str(err)) def handle(self, *args, **options): if len(args) != 0: diff --git a/lms/djangoapps/django_comment_client/permissions.py b/lms/djangoapps/django_comment_client/permissions.py index 3c83556a7e..4003dfb460 100644 --- a/lms/djangoapps/django_comment_client/permissions.py +++ b/lms/djangoapps/django_comment_client/permissions.py @@ -15,7 +15,8 @@ def cached_has_permission(user, permission, course_id=None): Call has_permission if it's not cached. A change in a user's role or a role's permissions will only become effective after CACHE_LIFESPAN seconds. """ - key = "permission_%d_%s_%s" % (user.id, str(course_id), permission) + key = u"permission_{user_id:d}_{course_id}_{permission}".format( + user_id=user.id, course_id=course_id, permission=permission) val = CACHE.get(key, None) if val not in [True, False]: val = has_permission(user, permission, course_id=course_id) diff --git a/lms/djangoapps/django_comment_client/tests/test.mustache b/lms/djangoapps/django_comment_client/tests/test.mustache new file mode 100644 index 0000000000..b69057d5ae --- /dev/null +++ b/lms/djangoapps/django_comment_client/tests/test.mustache @@ -0,0 +1 @@ +Testing 1 2 3. diff --git a/lms/djangoapps/django_comment_client/tests/test_utils.py b/lms/djangoapps/django_comment_client/tests/test_utils.py index 8acb5a057e..a35f6d8b78 100644 --- a/lms/djangoapps/django_comment_client/tests/test_utils.py +++ b/lms/djangoapps/django_comment_client/tests/test_utils.py @@ -1,4 +1,5 @@ import json +import mock from datetime import datetime from pytz import UTC from django.core.urlresolvers import reverse @@ -11,6 +12,7 @@ import django_comment_client.utils as utils from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE +from edxmako import add_lookup class DictionaryTestCase(TestCase): @@ -505,3 +507,17 @@ class JsonResponseTestCase(TestCase, UnicodeTestMixin): response = utils.JsonResponse(text) reparsed = json.loads(response.content) self.assertEqual(reparsed, text) + + +class RenderMustacheTests(TestCase): + """ + Test the `render_mustache` utility function. + """ + + @mock.patch('edxmako.LOOKUP', {}) + def test_it(self): + """ + Basic test. + """ + add_lookup('main', '', package=__name__) + self.assertEqual(utils.render_mustache('test.mustache', {}), 'Testing 1 2 3.\n') diff --git a/lms/djangoapps/django_comment_client/utils.py b/lms/djangoapps/django_comment_client/utils.py index 4aefbc67af..61b7460b55 100644 --- a/lms/djangoapps/django_comment_client/utils.py +++ b/lms/djangoapps/django_comment_client/utils.py @@ -1,7 +1,6 @@ import pytz from collections import defaultdict import logging -import urllib from datetime import datetime from django.contrib.auth.models import User @@ -12,7 +11,7 @@ from django.utils import simplejson from django_comment_common.models import Role, FORUM_ROLE_STUDENT from django_comment_client.permissions import check_permissions_by_view -import edxmako +from edxmako import lookup_template import pystache_custom as pystache from xmodule.modulestore.django import modulestore @@ -307,7 +306,7 @@ def get_metadata_for_threads(course_id, threads, user, user_info): def render_mustache(template_name, dictionary, *args, **kwargs): - template = edxmako.lookup['main'].get_template(template_name).source + template = lookup_template('main', template_name).source return pystache.render(template, dictionary) @@ -362,7 +361,7 @@ def safe_content(content): 'at_position_list', 'children', 'highlighted_title', 'highlighted_body', 'courseware_title', 'courseware_url', 'unread_comments_count', 'read', 'group_id', 'group_name', 'group_string', 'pinned', 'abuse_flaggers', - 'stats' + 'stats', 'resp_skip', 'resp_limit', 'resp_total', ] diff --git a/lms/djangoapps/instructor/views/legacy.py b/lms/djangoapps/instructor/views/legacy.py index ae740ad319..c671b28922 100644 --- a/lms/djangoapps/instructor/views/legacy.py +++ b/lms/djangoapps/instructor/views/legacy.py @@ -173,7 +173,7 @@ def instructor_dashboard(request, course_id): # complete the url using information about the current course: (org, course_name, _) = course_id.split("/") - return "i4x://" + org + "/" + course_name + "/" + urlname + return u"i4x://{org}/{name}/{url}".format(org=org, name=course_name, url=urlname) def get_student_from_identifier(unique_student_identifier): """Gets a student object using either an email address or username""" @@ -782,7 +782,7 @@ def instructor_dashboard(request, course_id): logs and swallows errors. """ url = settings.ANALYTICS_SERVER_URL + \ - "get?aname={}&course_id={}&apikey={}".format(analytics_name, + u"get?aname={}&course_id={}&apikey={}".format(analytics_name, course_id, settings.ANALYTICS_API_KEY) try: diff --git a/lms/djangoapps/instructor_task/tasks_helper.py b/lms/djangoapps/instructor_task/tasks_helper.py index 23d51a9ad7..4c7429def5 100644 --- a/lms/djangoapps/instructor_task/tasks_helper.py +++ b/lms/djangoapps/instructor_task/tasks_helper.py @@ -181,7 +181,7 @@ def run_main_task(entry_id, task_fcn, action_name): task_input = json.loads(entry.task_input) # construct log message: - fmt = 'task "{task_id}": course "{course_id}" input "{task_input}"' + fmt = u'task "{task_id}": course "{course_id}" input "{task_input}"' task_info_string = fmt.format(task_id=task_id, course_id=course_id, task_input=task_input) TASK_LOG.info('Starting update (nothing %s yet): %s', action_name, task_info_string) @@ -190,7 +190,7 @@ def run_main_task(entry_id, task_fcn, action_name): # that is running. request_task_id = _get_current_task().request.id if task_id != request_task_id: - fmt = 'Requested task did not match actual task "{actual_id}": {task_info}' + fmt = u'Requested task did not match actual task "{actual_id}": {task_info}' message = fmt.format(actual_id=request_task_id, task_info=task_info_string) TASK_LOG.error(message) raise ValueError(message) @@ -416,15 +416,15 @@ def rescore_problem_module_state(xmodule_instance_args, module_descriptor, stude if 'success' not in result: # don't consider these fatal, but false means that the individual call didn't complete: TASK_LOG.warning(u"error processing rescore call for course {course}, problem {loc} and student {student}: " - "unexpected response {msg}".format(msg=result, course=course_id, loc=module_state_key, student=student)) + u"unexpected response {msg}".format(msg=result, course=course_id, loc=module_state_key, student=student)) return UPDATE_STATUS_FAILED elif result['success'] not in ['correct', 'incorrect']: TASK_LOG.warning(u"error processing rescore call for course {course}, problem {loc} and student {student}: " - "{msg}".format(msg=result['success'], course=course_id, loc=module_state_key, student=student)) + u"{msg}".format(msg=result['success'], course=course_id, loc=module_state_key, student=student)) return UPDATE_STATUS_FAILED else: TASK_LOG.debug(u"successfully processed rescore call for course {course}, problem {loc} and student {student}: " - "{msg}".format(msg=result['success'], course=course_id, loc=module_state_key, student=student)) + u"{msg}".format(msg=result['success'], course=course_id, loc=module_state_key, student=student)) return UPDATE_STATUS_SUCCEEDED @@ -558,7 +558,7 @@ def push_grades_to_s3(_xmodule_instance_args, _entry_id, course_id, _task_input, grades_store = GradesStore.from_config() grades_store.store_rows( course_id, - "{}_grade_report_{}.csv".format(course_id_prefix, timestamp_str), + u"{}_grade_report_{}.csv".format(course_id_prefix, timestamp_str), rows ) @@ -566,7 +566,7 @@ def push_grades_to_s3(_xmodule_instance_args, _entry_id, course_id, _task_input, if len(err_rows) > 1: grades_store.store_rows( course_id, - "{}_grade_report_{}_err.csv".format(course_id_prefix, timestamp_str), + u"{}_grade_report_{}_err.csv".format(course_id_prefix, timestamp_str), err_rows ) diff --git a/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py b/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py index 35274ddd36..ae1e553e69 100644 --- a/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py +++ b/lms/djangoapps/linkedin/management/commands/linkedin_mailusers.py @@ -217,7 +217,7 @@ class Command(BaseCommand): """ Send an email. Return True if it succeeded, False if it didn't. """ - fromaddr = settings.DEFAULT_FROM_EMAIL + fromaddr = u'no-reply@notifier.edx.org' toaddr = u'{} <{}>'.format(user.profile.name, user.email) msg = EmailMessage(subject, body, fromaddr, (toaddr,)) msg.content_subtype = "html" diff --git a/lms/djangoapps/open_ended_grading/open_ended_notifications.py b/lms/djangoapps/open_ended_grading/open_ended_notifications.py index e99f51283c..9398a11957 100644 --- a/lms/djangoapps/open_ended_grading/open_ended_notifications.py +++ b/lms/djangoapps/open_ended_grading/open_ended_notifications.py @@ -66,7 +66,7 @@ def staff_grading_notifications(course, user): def peer_grading_notifications(course, user): system = LmsModuleSystem( track_function=None, - get_module = None, + get_module=None, render_template=render_to_string, replace_urls=None, ) @@ -115,7 +115,7 @@ def combined_notifications(course, user): #Set up return values so that we can return them for error cases pending_grading = False img_path = "" - notifications={} + notifications = {} notification_dict = {'pending_grading': pending_grading, 'img_path': img_path, 'response': notifications} #We don't want to show anonymous users anything. @@ -126,7 +126,7 @@ def combined_notifications(course, user): system = LmsModuleSystem( static_url="/static", track_function=None, - get_module = None, + get_module=None, render_template=render_to_string, replace_urls=None, ) @@ -159,7 +159,7 @@ def combined_notifications(course, user): #Non catastrophic error, so no real action #This is a dev_facing_error log.exception( - "Problem with getting notifications from controller query service for course {0} user {1}.".format( + u"Problem with getting notifications from controller query service for course {0} user {1}.".format( course_id, student_id)) if pending_grading: @@ -185,7 +185,7 @@ def set_value_in_cache(student_id, course_id, notification_type, value): def create_key_name(student_id, course_id, notification_type): - key_name = "{prefix}{type}_{course}_{student}".format(prefix=KEY_PREFIX, type=notification_type, course=course_id, + key_name = u"{prefix}{type}_{course}_{student}".format(prefix=KEY_PREFIX, type=notification_type, course=course_id, student=student_id) return key_name diff --git a/lms/djangoapps/open_ended_grading/staff_grading_service.py b/lms/djangoapps/open_ended_grading/staff_grading_service.py index 090b36d13c..81a2eb401c 100644 --- a/lms/djangoapps/open_ended_grading/staff_grading_service.py +++ b/lms/djangoapps/open_ended_grading/staff_grading_service.py @@ -66,7 +66,6 @@ class MockStaffGradingService(object): 'min_for_ml': 10}) ]}) - def save_grade(self, course_id, grader_id, submission_id, score, feedback, skipped, rubric_scores, submission_flagged): return self.get_next(course_id, 'fake location', grader_id) @@ -81,7 +80,7 @@ class StaffGradingService(GradingService): config['system'] = LmsModuleSystem( static_url='/static', track_function=None, - get_module = None, + get_module=None, render_template=render_to_string, replace_urls=None, ) @@ -93,7 +92,6 @@ class StaffGradingService(GradingService): self.get_problem_list_url = self.url + '/get_problem_list/' self.get_notifications_url = self.url + "/get_notifications/" - def get_problem_list(self, course_id, grader_id): """ Get the list of problems for a given course. @@ -113,7 +111,6 @@ class StaffGradingService(GradingService): params = {'course_id': course_id, 'grader_id': grader_id} return self.get(self.get_problem_list_url, params) - def get_next(self, course_id, location, grader_id): """ Get the next thing to grade. @@ -137,7 +134,6 @@ class StaffGradingService(GradingService): 'grader_id': grader_id}) return json.dumps(self._render_rubric(response)) - def save_grade(self, course_id, grader_id, submission_id, score, feedback, skipped, rubric_scores, submission_flagged): """ diff --git a/lms/djangoapps/verify_student/exceptions.py b/lms/djangoapps/verify_student/exceptions.py new file mode 100644 index 0000000000..d31fdb6a6d --- /dev/null +++ b/lms/djangoapps/verify_student/exceptions.py @@ -0,0 +1,9 @@ +""" +Exceptions for the verify student app +""" +# (Exception Class Names are sort of self-explanatory, so skipping docstring requirement) +# pylint: disable=C0111 + + +class WindowExpiredException(Exception): + pass diff --git a/lms/djangoapps/verify_student/migrations/0002_auto__add_field_softwaresecurephotoverification_window.py b/lms/djangoapps/verify_student/migrations/0002_auto__add_field_softwaresecurephotoverification_window.py new file mode 100644 index 0000000000..0672427d31 --- /dev/null +++ b/lms/djangoapps/verify_student/migrations/0002_auto__add_field_softwaresecurephotoverification_window.py @@ -0,0 +1,88 @@ +# -*- 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 field 'SoftwareSecurePhotoVerification.window' + db.add_column('verify_student_softwaresecurephotoverification', 'window', + self.gf('django.db.models.fields.related.ForeignKey')(to=orm['reverification.MidcourseReverificationWindow'], null=True), + keep_default=False) + + + def backwards(self, orm): + # Deleting field 'SoftwareSecurePhotoVerification.window' + db.delete_column('verify_student_softwaresecurephotoverification', 'window_id') + + + 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'}) + }, + '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'}) + }, + 'verify_student.softwaresecurephotoverification': { + 'Meta': {'ordering': "['-created_at']", 'object_name': 'SoftwareSecurePhotoVerification'}, + 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'error_code': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}), + 'error_msg': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'face_image_url': ('django.db.models.fields.URLField', [], {'max_length': '255', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'photo_id_image_url': ('django.db.models.fields.URLField', [], {'max_length': '255', 'blank': 'True'}), + 'photo_id_key': ('django.db.models.fields.TextField', [], {'max_length': '1024'}), + 'receipt_id': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '255', 'db_index': 'True'}), + 'reviewing_service': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'reviewing_user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': "'photo_verifications_reviewed'", 'null': 'True', 'to': "orm['auth.User']"}), + 'status': ('model_utils.fields.StatusField', [], {'default': "'created'", 'max_length': '100', u'no_check_for_status': 'True'}), + 'status_changed': ('model_utils.fields.MonitorField', [], {'default': 'datetime.datetime.now', u'monitor': "u'status'"}), + 'submitted_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'window': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['reverification.MidcourseReverificationWindow']", 'null': 'True'}) + } + } + + complete_apps = ['verify_student'] \ No newline at end of file diff --git a/lms/djangoapps/verify_student/migrations/0003_auto__add_field_softwaresecurephotoverification_display.py b/lms/djangoapps/verify_student/migrations/0003_auto__add_field_softwaresecurephotoverification_display.py new file mode 100644 index 0000000000..3c41289180 --- /dev/null +++ b/lms/djangoapps/verify_student/migrations/0003_auto__add_field_softwaresecurephotoverification_display.py @@ -0,0 +1,89 @@ +# -*- 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 field 'SoftwareSecurePhotoVerification.display' + db.add_column('verify_student_softwaresecurephotoverification', 'display', + self.gf('django.db.models.fields.BooleanField')(default=True, db_index=True), + keep_default=False) + + + def backwards(self, orm): + # Deleting field 'SoftwareSecurePhotoVerification.display' + db.delete_column('verify_student_softwaresecurephotoverification', 'display') + + + 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'}) + }, + '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'}) + }, + 'verify_student.softwaresecurephotoverification': { + 'Meta': {'ordering': "['-created_at']", 'object_name': 'SoftwareSecurePhotoVerification'}, + 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'display': ('django.db.models.fields.BooleanField', [], {'default': 'True', 'db_index': 'True'}), + 'error_code': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}), + 'error_msg': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'face_image_url': ('django.db.models.fields.URLField', [], {'max_length': '255', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'photo_id_image_url': ('django.db.models.fields.URLField', [], {'max_length': '255', 'blank': 'True'}), + 'photo_id_key': ('django.db.models.fields.TextField', [], {'max_length': '1024'}), + 'receipt_id': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '255', 'db_index': 'True'}), + 'reviewing_service': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'reviewing_user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': "'photo_verifications_reviewed'", 'null': 'True', 'to': "orm['auth.User']"}), + 'status': ('model_utils.fields.StatusField', [], {'default': "'created'", 'max_length': '100', u'no_check_for_status': 'True'}), + 'status_changed': ('model_utils.fields.MonitorField', [], {'default': 'datetime.datetime.now', u'monitor': "u'status'"}), + 'submitted_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'window': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['reverification.MidcourseReverificationWindow']", 'null': 'True'}) + } + } + + complete_apps = ['verify_student'] \ No newline at end of file diff --git a/lms/djangoapps/verify_student/models.py b/lms/djangoapps/verify_student/models.py index dccbdb430a..e8387dff08 100644 --- a/lms/djangoapps/verify_student/models.py +++ b/lms/djangoapps/verify_student/models.py @@ -35,9 +35,16 @@ from verify_student.ssencrypt import ( generate_signed_message, rsa_encrypt ) +from reverification.models import MidcourseReverificationWindow + log = logging.getLogger(__name__) +def generateUUID(): # pylint: disable=C0103 + """ Utility function; generates UUIDs """ + return str(uuid.uuid4) + + class VerificationException(Exception): pass @@ -135,13 +142,18 @@ class PhotoVerification(StatusModel): # user IDs or something too easily guessable. receipt_id = models.CharField( db_index=True, - default=uuid.uuid4, + default=generateUUID, max_length=255, ) created_at = models.DateTimeField(auto_now_add=True, db_index=True) updated_at = models.DateTimeField(auto_now=True, db_index=True) + # Indicates whether or not a user wants to see the verification status + # displayed on their dash. Right now, only relevant for allowing students + # to "dismiss" a failed midcourse reverification message + display = models.BooleanField(db_index=True, default=True) + ######################## Fields Set When Submitting ######################## submitted_at = models.DateTimeField(null=True, db_index=True) @@ -185,52 +197,67 @@ class PhotoVerification(StatusModel): return allowed_date @classmethod - def user_is_verified(cls, user, earliest_allowed_date=None): + def user_is_verified(cls, user, earliest_allowed_date=None, window=None): """ - Return whether or not a user has satisfactorily proved their - identity. Depending on the policy, this can expire after some period of - time, so a user might have to renew periodically. + Return whether or not a user has satisfactorily proved their identity. + Depending on the policy, this can expire after some period of time, so + a user might have to renew periodically. + + If window=None, then this will check for the user's *initial* verification. + If window is set to anything else, it will check for the reverification + associated with that window. """ return cls.objects.filter( user=user, status="approved", created_at__gte=(earliest_allowed_date - or cls._earliest_allowed_date()) + or cls._earliest_allowed_date()), + window=window ).exists() @classmethod - def user_has_valid_or_pending(cls, user, earliest_allowed_date=None): + def user_has_valid_or_pending(cls, user, earliest_allowed_date=None, window=None): """ Return whether the user has a complete verification attempt that is or *might* be good. This means that it's approved, been submitted, or would have been submitted but had an non-user error when it was being submitted. It's basically any situation in which the user has signed off on the contents of the attempt, and we have not yet received a denial. + + If window=None, this will check for the user's *initial* verification. If + window is anything else, this will check for the reverification associated + with that window. """ - valid_statuses = ['must_retry', 'submitted', 'approved'] + valid_statuses = ['submitted', 'approved'] + if not window: + valid_statuses.append('must_retry') return cls.objects.filter( user=user, status__in=valid_statuses, created_at__gte=(earliest_allowed_date - or cls._earliest_allowed_date()) + or cls._earliest_allowed_date()), + window=window, ).exists() @classmethod - def active_for_user(cls, user): + def active_for_user(cls, user, window=None): """ Return the most recent PhotoVerification that is marked ready (i.e. the user has said they're set, but we haven't submitted anything yet). + + If window=None, this checks for the original verification. If window is set to + anything else, this will check for the reverification associated with that window. """ # This should only be one at the most, but just in case we create more # by mistake, we'll grab the most recently created one. - active_attempts = cls.objects.filter(user=user, status='ready').order_by('-created_at') + active_attempts = cls.objects.filter(user=user, status='ready', window=window).order_by('-created_at') if active_attempts: return active_attempts[0] else: return None @classmethod - def user_status(cls, user): + def user_status(cls, user, window=None): """ Returns the status of the user based on their past verification attempts @@ -239,36 +266,53 @@ class PhotoVerification(StatusModel): If the verification has been approved, returns 'approved' If the verification process is still ongoing, returns 'pending' If the verification has been denied and the user must resubmit photos, returns 'must_reverify' + + If window=None, this checks initial verifications + If window is set, this checks for the reverification associated with that window """ status = 'none' error_msg = '' - if cls.user_is_verified(user): + if cls.user_is_verified(user, window=window): status = 'approved' - elif cls.user_has_valid_or_pending(user): + + elif cls.user_has_valid_or_pending(user, window=window): # user_has_valid_or_pending does include 'approved', but if we are # here, we know that the attempt is still pending status = 'pending' + else: # we need to check the most recent attempt to see if we need to ask them to do # a retry try: - attempts = cls.objects.filter(user=user).order_by('-updated_at') + attempts = cls.objects.filter(user=user, window=window).order_by('-updated_at') attempt = attempts[0] except IndexError: - return ('none', error_msg) + + # If no verification exists for a *midcourse* reverification, then that just + # means the student still needs to reverify. For *original* verifications, + # we return 'none' + if(window): + return('must_reverify', error_msg) + else: + return ('none', error_msg) + if attempt.created_at < cls._earliest_allowed_date(): return ('expired', error_msg) - # right now, this is the only state at which they must reverify. It - # may change later + # If someone is denied their original verification attempt, they can try to reverify. + # However, if a midcourse reverification is denied, that denial is permanent. if attempt.status == 'denied': - status = 'must_reverify' + if window is None: + status = 'must_reverify' + else: + status = 'denied' if attempt.error_msg: error_msg = attempt.parsed_error_msg() return (status, error_msg) + def parsed_error_msg(self): """ Sometimes, the error message we've received needs to be parsed into @@ -320,10 +364,6 @@ class PhotoVerification(StatusModel): self.status = "ready" self.save() - @status_before_must_be("must_retry", "ready", "submitted") - def submit(self): - raise NotImplementedError - @status_before_must_be("must_retry", "submitted", "approved", "denied") def approve(self, user_id=None, service=""): """ @@ -429,6 +469,28 @@ class PhotoVerification(StatusModel): self.status = "must_retry" self.save() + @classmethod + def display_off(cls, user_id): + """ + Find all failed PhotoVerifications for a user, and sets those verifications' `display` + property to false, so the notification banner can be switched off. + """ + user = User.objects.get(id=user_id) + cls.objects.filter(user=user, status="denied").exclude(window=None).update(display=False) + + @classmethod + def display_status(cls, user, window): + """ + Finds the `display` property for the PhotoVerification associated with + (user, window). Default is True + """ + attempts = cls.objects.filter(user=user, window=window).order_by('-updated_at') + try: + attempt = attempts[0] + return attempt.display + except IndexError: + return True + class SoftwareSecurePhotoVerification(PhotoVerification): """ @@ -454,6 +516,12 @@ class SoftwareSecurePhotoVerification(PhotoVerification): 3. The encrypted photos are base64 encoded and stored in an S3 bucket that edx-platform does not have read access to. + + Note: this model handles both *inital* verifications (which you must perform + at the time you register for a verified cert), and *midcourse reverifications*. + To distinguish between the two, check the value of the property window: + intial verifications of a window of None, whereas midcourse reverifications + * must always be linked to a specific window*. """ # This is a base64.urlsafe_encode(rsa_encrypt(photo_id_aes_key), ss_pub_key) # So first we generate a random AES-256 key to encrypt our photo ID with. @@ -463,6 +531,43 @@ class SoftwareSecurePhotoVerification(PhotoVerification): IMAGE_LINK_DURATION = 5 * 60 * 60 * 24 # 5 days in seconds + window = models.ForeignKey(MidcourseReverificationWindow, db_index=True, null=True) + + @classmethod + def user_is_reverified_for_all(cls, course_id, user): + """ + Checks to see if the student has successfully reverified for all of the + mandatory re-verification windows associated with a course. + + This is used primarily by the certificate generation code... if the user is + not re-verified for all windows, then they cannot receive a certificate. + """ + all_windows = MidcourseReverificationWindow.objects.filter(course_id=course_id) + # if there are no windows for a course, then return True right off + if (not all_windows.exists()): + return True + + for window in all_windows: + try: + # The status of the most recent reverification for each window must be "approved" + # for a student to count as completely reverified + attempts = cls.objects.filter(user=user, window=window).order_by('-updated_at') + attempt = attempts[0] + if attempt.status != "approved": + return False + except Exception: # pylint: disable=W0703 + return False + + return True + + @classmethod + def original_verification(cls, user): + """ + Returns the most current SoftwareSecurePhotoVerification object associated with the user. + """ + query = cls.objects.filter(user=user, window=None).order_by('-updated_at') + return query[0] + @status_before_must_be("created") def upload_face_image(self, img_data): """ @@ -483,9 +588,22 @@ class SoftwareSecurePhotoVerification(PhotoVerification): aes_key_str = settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["FACE_IMAGE_AES_KEY"] aes_key = aes_key_str.decode("hex") - s3_key = self._generate_key("face") + s3_key = self._generate_s3_key("face") s3_key.set_contents_from_string(encrypt_and_encode(img_data, aes_key)) + @status_before_must_be("created") + def fetch_photo_id_image(self): + """ + Find the user's photo ID image, which was submitted with their original verification. + The image has already been encrypted and stored in s3, so we just need to find that + location + """ + if settings.FEATURES.get('AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING'): + return + + self.photo_id_key = self.original_verification(self.user).photo_id_key + self.save() + @status_before_must_be("created") def upload_photo_id_image(self, img_data): """ @@ -510,7 +628,7 @@ class SoftwareSecurePhotoVerification(PhotoVerification): rsa_encrypted_aes_key = rsa_encrypt(aes_key, rsa_key_str) # Upload this to S3 - s3_key = self._generate_key("photo_id") + s3_key = self._generate_s3_key("photo_id") s3_key.set_contents_from_string(encrypt_and_encode(img_data, aes_key)) # Update our record fields @@ -580,11 +698,13 @@ class SoftwareSecurePhotoVerification(PhotoVerification): We dynamically generate this, since we want it the expiration clock to start when the message is created, not when the record is created. """ - s3_key = self._generate_key(name) + s3_key = self._generate_s3_key(name) return s3_key.generate_url(self.IMAGE_LINK_DURATION) - def _generate_key(self, prefix): + def _generate_s3_key(self, prefix): """ + Generates a key for an s3 bucket location + Example: face/4dd1add9-6719-42f7-bea0-115c008c4fca """ conn = S3Connection( @@ -659,6 +779,7 @@ class SoftwareSecurePhotoVerification(PhotoVerification): return header_txt + "\n\n" + body_txt + def send_request(self): """ Assembles a submission to Software Secure and sends it via HTTPS. diff --git a/lms/djangoapps/verify_student/tests/test_models.py b/lms/djangoapps/verify_student/tests/test_models.py index fd2b767859..beac953dec 100644 --- a/lms/djangoapps/verify_student/tests/test_models.py +++ b/lms/djangoapps/verify_student/tests/test_models.py @@ -1,18 +1,25 @@ # -*- coding: utf-8 -*- -from datetime import timedelta +from datetime import timedelta, datetime import json +from xmodule.modulestore.tests.factories import CourseFactory from nose.tools import ( assert_in, assert_is_none, assert_equals, assert_not_equals, assert_raises, assert_true, assert_false ) from mock import MagicMock, patch +import pytz from django.test import TestCase +from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE +from django.test.utils import override_settings from django.conf import settings import requests import requests.exceptions from student.tests.factories import UserFactory -from verify_student.models import SoftwareSecurePhotoVerification, VerificationException +from verify_student.models import ( + SoftwareSecurePhotoVerification, VerificationException, +) +from reverification.tests.factories import MidcourseReverificationWindowFactory from util.testing import UrlResetMixin import verify_student.models @@ -208,6 +215,23 @@ class TestPhotoVerification(TestCase): return attempt + def test_fetch_photo_id_image(self): + user = UserFactory.create() + orig_attempt = SoftwareSecurePhotoVerification(user=user, window=None) + orig_attempt.save() + + old_key = orig_attempt.photo_id_key + + window = MidcourseReverificationWindowFactory( + course_id="ponies", + start_date=datetime.now(pytz.utc) - timedelta(days=5), + end_date=datetime.now(pytz.utc) + timedelta(days=5) + ) + new_attempt = SoftwareSecurePhotoVerification(user=user, window=window) + new_attempt.save() + new_attempt.fetch_photo_id_image() + assert_equals(new_attempt.photo_id_key, old_key) + def test_submissions(self): """Test that we set our status correctly after a submission.""" # Basic case, things go well. @@ -339,6 +363,37 @@ class TestPhotoVerification(TestCase): status = SoftwareSecurePhotoVerification.user_status(user) self.assertEquals(status, ('must_reverify', "No photo ID was provided.")) + # test for correct status for reverifications + window = MidcourseReverificationWindowFactory() + reverify_status = SoftwareSecurePhotoVerification.user_status(user=user, window=window) + self.assertEquals(reverify_status, ('must_reverify', '')) + + reverify_attempt = SoftwareSecurePhotoVerification(user=user, window=window) + reverify_attempt.status = 'approved' + reverify_attempt.save() + + reverify_status = SoftwareSecurePhotoVerification.user_status(user=user, window=window) + self.assertEquals(reverify_status, ('approved', '')) + + reverify_attempt.status = 'denied' + reverify_attempt.save() + + reverify_status = SoftwareSecurePhotoVerification.user_status(user=user, window=window) + self.assertEquals(reverify_status, ('denied', '')) + + def test_display(self): + user = UserFactory.create() + window = MidcourseReverificationWindowFactory() + attempt = SoftwareSecurePhotoVerification(user=user, window=window, status="denied") + attempt.save() + + # We expect the verification to be displayed by default + self.assertEquals(SoftwareSecurePhotoVerification.display_status(user, window), True) + + # Turn it off + SoftwareSecurePhotoVerification.display_off(user.id) + self.assertEquals(SoftwareSecurePhotoVerification.display_status(user, window), False) + def test_parse_error_msg_success(self): user = UserFactory.create() attempt = SoftwareSecurePhotoVerification(user=user) @@ -362,3 +417,101 @@ class TestPhotoVerification(TestCase): attempt.error_msg = msg parsed_error_msg = attempt.parsed_error_msg() self.assertEquals(parsed_error_msg, "There was an error verifying your ID photos.") + + +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +@patch.dict(settings.VERIFY_STUDENT, FAKE_SETTINGS) +@patch('verify_student.models.S3Connection', new=MockS3Connection) +@patch('verify_student.models.Key', new=MockKey) +@patch('verify_student.models.requests.post', new=mock_software_secure_post) +class TestMidcourseReverification(TestCase): + """ Tests for methods that are specific to midcourse SoftwareSecurePhotoVerification objects """ + def setUp(self): + self.course_id = "MITx/999/Robot_Super_Course" + self.course = CourseFactory.create(org='MITx', number='999', display_name='Robot Super Course') + self.user = UserFactory.create() + + def test_user_is_reverified_for_all(self): + + # if there are no windows for a course, this should return True + self.assertTrue(SoftwareSecurePhotoVerification.user_is_reverified_for_all(self.course_id, self.user)) + + # first, make three windows + window1 = MidcourseReverificationWindowFactory( + course_id=self.course_id, + start_date=datetime.now(pytz.UTC) - timedelta(days=15), + end_date=datetime.now(pytz.UTC) - timedelta(days=13), + ) + + window2 = MidcourseReverificationWindowFactory( + course_id=self.course_id, + start_date=datetime.now(pytz.UTC) - timedelta(days=10), + end_date=datetime.now(pytz.UTC) - timedelta(days=8), + ) + + window3 = MidcourseReverificationWindowFactory( + course_id=self.course_id, + start_date=datetime.now(pytz.UTC) - timedelta(days=5), + end_date=datetime.now(pytz.UTC) - timedelta(days=3), + ) + + # make two SSPMidcourseReverifications for those windows + attempt1 = SoftwareSecurePhotoVerification( + status="approved", + user=self.user, + window=window1 + ) + attempt1.save() + + attempt2 = SoftwareSecurePhotoVerification( + status="approved", + user=self.user, + window=window2 + ) + attempt2.save() + + # should return False because only 2 of 3 windows have verifications + self.assertFalse(SoftwareSecurePhotoVerification.user_is_reverified_for_all(self.course_id, self.user)) + + attempt3 = SoftwareSecurePhotoVerification( + status="must_retry", + user=self.user, + window=window3 + ) + attempt3.save() + + # should return False because the last verification exists BUT is not approved + self.assertFalse(SoftwareSecurePhotoVerification.user_is_reverified_for_all(self.course_id, self.user)) + + attempt3.status = "approved" + attempt3.save() + + # should now return True because all windows have approved verifications + self.assertTrue(SoftwareSecurePhotoVerification.user_is_reverified_for_all(self.course_id, self.user)) + + def test_original_verification(self): + orig_attempt = SoftwareSecurePhotoVerification(user=self.user) + orig_attempt.save() + window = MidcourseReverificationWindowFactory( + course_id=self.course_id, + start_date=datetime.now(pytz.UTC) - timedelta(days=15), + end_date=datetime.now(pytz.UTC) - timedelta(days=13), + ) + midcourse_attempt = SoftwareSecurePhotoVerification(user=self.user, window=window) + self.assertEquals(midcourse_attempt.original_verification(user=self.user), orig_attempt) + + def test_user_has_valid_or_pending(self): + window = MidcourseReverificationWindowFactory( + course_id=self.course_id, + start_date=datetime.now(pytz.UTC) - timedelta(days=15), + end_date=datetime.now(pytz.UTC) - timedelta(days=13), + ) + + attempt = SoftwareSecurePhotoVerification(status="must_retry", user=self.user, window=window) + attempt.save() + + assert_false(SoftwareSecurePhotoVerification.user_has_valid_or_pending(user=self.user, window=window)) + + attempt.status = "approved" + attempt.save() + assert_true(SoftwareSecurePhotoVerification.user_has_valid_or_pending(user=self.user, window=window)) diff --git a/lms/djangoapps/verify_student/tests/test_views.py b/lms/djangoapps/verify_student/tests/test_views.py index c14f41d87b..4ddef86f18 100644 --- a/lms/djangoapps/verify_student/tests/test_views.py +++ b/lms/djangoapps/verify_student/tests/test_views.py @@ -11,6 +11,8 @@ verify_student/start?course_id=MITx/6.002x/2013_Spring # create """ import urllib from mock import patch, Mock, ANY +import pytz +from datetime import timedelta, datetime from django.test import TestCase from django.test.utils import override_settings @@ -18,12 +20,16 @@ from django.conf import settings from django.core.urlresolvers import reverse from django.core.exceptions import ObjectDoesNotExist +from mock import sentinel + from xmodule.modulestore.tests.factories import CourseFactory from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE from student.tests.factories import UserFactory +from student.models import CourseEnrollment from course_modes.models import CourseMode from verify_student.views import render_to_response from verify_student.models import SoftwareSecurePhotoVerification +from reverification.tests.factories import MidcourseReverificationWindowFactory def mock_render_to_response(*args, **kwargs): @@ -80,6 +86,8 @@ class TestReverifyView(TestCase): def setUp(self): self.user = UserFactory.create(username="rusty", password="test") self.client.login(username="rusty", password="test") + self.course_id = "MITx/999/Robot_Super_Course" + self.course = CourseFactory.create(org='MITx', number='999', display_name='Robot Super Course') @patch('verify_student.views.render_to_response', render_mock) def test_reverify_get(self): @@ -110,3 +118,100 @@ class TestReverifyView(TestCase): self.assertIsNotNone(verification_attempt) except ObjectDoesNotExist: self.fail('No verification object generated') + ((template, context), _kwargs) = render_mock.call_args + self.assertIn('photo_reverification', template) + self.assertTrue(context['error']) + + +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +class TestMidCourseReverifyView(TestCase): + """ Tests for the midcourse reverification views """ + def setUp(self): + self.user = UserFactory.create(username="rusty", password="test") + self.client.login(username="rusty", password="test") + self.course_id = 'Robot/999/Test_Course' + CourseFactory.create(org='Robot', number='999', display_name='Test Course') + + patcher = patch('student.models.server_track') + self.mock_server_track = patcher.start() + self.addCleanup(patcher.stop) + + crum_patcher = patch('student.models.crum.get_current_request') + self.mock_get_current_request = crum_patcher.start() + self.addCleanup(crum_patcher.stop) + self.mock_get_current_request.return_value = sentinel.request + + @patch('verify_student.views.render_to_response', render_mock) + def test_midcourse_reverify_get(self): + url = reverse('verify_student_midcourse_reverify', + kwargs={"course_id": self.course_id}) + response = self.client.get(url) + + # Check that user entering the reverify flow was logged + self.mock_server_track.assert_called_once_with( + sentinel.request, + 'edx.course.enrollment.reverify.started', + { + 'user_id': self.user.id, + 'course_id': self.course_id, + 'mode': "verified", + } + ) + self.mock_server_track.reset_mock() + + self.assertEquals(response.status_code, 200) + ((_template, context), _kwargs) = render_mock.call_args + self.assertFalse(context['error']) + + @patch.dict(settings.FEATURES, {'AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING': True}) + def test_midcourse_reverify_post_success(self): + window = MidcourseReverificationWindowFactory(course_id=self.course_id) + url = reverse('verify_student_midcourse_reverify', kwargs={'course_id': self.course_id}) + + response = self.client.post(url, {'face_image': ','}) + + # Check that submission event was logged + self.mock_server_track.assert_called_once_with( + sentinel.request, + 'edx.course.enrollment.reverify.submitted', + { + 'user_id': self.user.id, + 'course_id': self.course_id, + 'mode': "verified", + } + ) + self.mock_server_track.reset_mock() + + self.assertEquals(response.status_code, 302) + try: + verification_attempt = SoftwareSecurePhotoVerification.objects.get(user=self.user, window=window) + self.assertIsNotNone(verification_attempt) + except ObjectDoesNotExist: + self.fail('No verification object generated') + + @patch.dict(settings.FEATURES, {'AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING': True}) + def test_midcourse_reverify_post_failure_expired_window(self): + window = MidcourseReverificationWindowFactory( + course_id=self.course_id, + start_date=datetime.now(pytz.UTC) - timedelta(days=100), + end_date=datetime.now(pytz.UTC) - timedelta(days=50), + ) + url = reverse('verify_student_midcourse_reverify', kwargs={'course_id': self.course_id}) + response = self.client.post(url, {'face_image': ','}) + self.assertEquals(response.status_code, 302) + with self.assertRaises(ObjectDoesNotExist): + SoftwareSecurePhotoVerification.objects.get(user=self.user, window=window) + + @patch('verify_student.views.render_to_response', render_mock) + def test_midcourse_reverify_dash(self): + url = reverse('verify_student_midcourse_reverify_dash') + response = self.client.get(url) + # not enrolled in any courses + self.assertEquals(response.status_code, 200) + + enrollment = CourseEnrollment.get_or_create_enrollment(self.user, self.course_id) + enrollment.update_enrollment(mode="verified", is_active=True) + MidcourseReverificationWindowFactory(course_id=self.course_id) + response = self.client.get(url) + # enrolled in a verified course, and the window is open + self.assertEquals(response.status_code, 200) diff --git a/lms/djangoapps/verify_student/urls.py b/lms/djangoapps/verify_student/urls.py index 843ebf9602..799264cda1 100644 --- a/lms/djangoapps/verify_student/urls.py +++ b/lms/djangoapps/verify_student/urls.py @@ -13,7 +13,7 @@ urlpatterns = patterns( url( r'^verify/(?P[^/]+/[^/]+/[^/]+)$', - views.VerifyView.as_view(), + views.VerifyView.as_view(), # pylint: disable=E1120 name="verify_student_verify" ), @@ -41,9 +41,39 @@ urlpatterns = patterns( name="verify_student_reverify" ), + url( + r'^midcourse_reverify/(?P[^/]+/[^/]+/[^/]+)$', + views.MidCourseReverifyView.as_view(), # pylint: disable=E1120 + name="verify_student_midcourse_reverify" + ), + url( r'^reverification_confirmation$', views.reverification_submission_confirmation, name="verify_student_reverification_confirmation" ), + + url( + r'^midcourse_reverification_confirmation$', + views.midcourse_reverification_confirmation, + name="verify_student_midcourse_reverification_confirmation" + ), + + url( + r'^midcourse_reverify_dash$', + views.midcourse_reverify_dash, + name="verify_student_midcourse_reverify_dash" + ), + + url( + r'^reverification_window_expired$', + views.reverification_window_expired, + name="verify_student_reverification_window_expired" + ), + + url( + r'^toggle_failed_banner_off$', + views.toggle_failed_banner_off, + name="verify_student_toggle_failed_banner_off" + ), ) diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index 2ac798bbf2..f8e5b464e9 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -5,6 +5,10 @@ Views for the verification flow import json import logging import decimal +import datetime +import crum +from track.views import server_track +from pytz import UTC from edxmako.shortcuts import render_to_response @@ -22,16 +26,25 @@ from django.contrib.auth.decorators import login_required from course_modes.models import CourseMode from student.models import CourseEnrollment -from student.views import course_from_id +from student.views import course_from_id, reverification_info from shoppingcart.models import Order, CertificateItem from shoppingcart.processors.CyberSource import ( get_signed_purchase_params, get_purchase_endpoint ) -from verify_student.models import SoftwareSecurePhotoVerification +from verify_student.models import ( + SoftwareSecurePhotoVerification, +) +from reverification.models import MidcourseReverificationWindow import ssencrypt +from xmodule.modulestore.exceptions import ItemNotFoundError +from .exceptions import WindowExpiredException log = logging.getLogger(__name__) +EVENT_NAME_USER_ENTERED_MIDCOURSE_REVERIFY_VIEW = 'edx.course.enrollment.reverify.started' +EVENT_NAME_USER_SUBMITTED_MIDCOURSE_REVERIFY = 'edx.course.enrollment.reverify.submitted' +EVENT_NAME_USER_REVERIFICATION_REVIEWED_BY_SOFTWARESECURE = 'edx.course.enrollment.reverify.reviewed' + class VerifyView(View): @method_decorator(login_required) @@ -42,7 +55,6 @@ class VerifyView(View): - Taking the id photo - Confirming that the photos and payment price are correct before proceeding to payment - """ upgrade = request.GET.get('upgrade', False) @@ -245,6 +257,13 @@ def results_callback(request): "Result {} not understood. Known results: PASS, FAIL, SYSTEM FAIL".format(result) ) + # If this is a reverification, log an event + if attempt.window: + course_id = window.course_id + course = course_from_id(course_id) + course_enrollment = CourseEnrollment.get_or_create_enrollment(attempt.user, course_id) + course_enrollment.emit_event(EVENT_NAME_USER_REVERIFICATION_REVIEWED_BY_SOFTWARESECURE) + return HttpResponse("OK!") @@ -323,10 +342,136 @@ class ReverifyView(View): return render_to_response("verify_student/photo_reverification.html", context) +class MidCourseReverifyView(View): + """ + The mid-course reverification view. + Needs to perform these functions: + - take new face photo + - retrieve the old id photo + - submit these photos to photo verification service + + Does not need to worry about pricing + """ + @method_decorator(login_required) + def get(self, request, course_id): + """ + display this view + """ + course = course_from_id(course_id) + course_enrollment = CourseEnrollment.get_or_create_enrollment(request.user, course_id) + course_enrollment.update_enrollment(mode="verified") + course_enrollment.emit_event(EVENT_NAME_USER_ENTERED_MIDCOURSE_REVERIFY_VIEW) + context = { + "user_full_name": request.user.profile.name, + "error": False, + "course_id": course_id, + "course_name": course.display_name_with_default, + "course_org": course.display_org_with_default, + "course_num": course.display_number_with_default, + "reverify": True, + } + + return render_to_response("verify_student/midcourse_photo_reverification.html", context) + + @method_decorator(login_required) + def post(self, request, course_id): + """ + submits the reverification to SoftwareSecure + """ + try: + now = datetime.datetime.now(UTC) + window = MidcourseReverificationWindow.get_window(course_id, now) + if window is None: + raise WindowExpiredException + attempt = SoftwareSecurePhotoVerification(user=request.user, window=window) + b64_face_image = request.POST['face_image'].split(",")[1] + + attempt.upload_face_image(b64_face_image.decode('base64')) + attempt.fetch_photo_id_image() + attempt.mark_ready() + + attempt.save() + attempt.submit() + course_enrollment = CourseEnrollment.get_or_create_enrollment(request.user, course_id) + course_enrollment.update_enrollment(mode="verified") + course_enrollment.emit_event(EVENT_NAME_USER_SUBMITTED_MIDCOURSE_REVERIFY) + return HttpResponseRedirect(reverse('verify_student_midcourse_reverification_confirmation')) + + except WindowExpiredException: + log.exception( + "User {} attempted to re-verify, but the window expired before the attempt".format(request.user.id) + ) + return HttpResponseRedirect(reverse('verify_student_reverification_window_expired')) + + except Exception: + log.exception( + "Could not submit verification attempt for user {}".format(request.user.id) + ) + context = { + "user_full_name": request.user.profile.name, + "error": True, + } + return render_to_response("verify_student/midcourse_photo_reverification.html", context) + + +@login_required +def midcourse_reverify_dash(request): + """ + Shows the "course reverification dashboard", which displays the reverification status (must reverify, + pending, approved, failed, etc) of all courses in which a student has a verified enrollment. + """ + user = request.user + course_enrollment_pairs = [] + for enrollment in CourseEnrollment.enrollments_for_user(user): + try: + course_enrollment_pairs.append((course_from_id(enrollment.course_id), enrollment)) + except ItemNotFoundError: + log.error("User {0} enrolled in non-existent course {1}" + .format(user.username, enrollment.course_id)) + + statuses = ["approved", "pending", "must_reverify", "denied"] + + reverifications = reverification_info(course_enrollment_pairs, user, statuses) + + context = { + "user_full_name": user.profile.name, + 'reverifications': reverifications, + 'referer': request.META.get('HTTP_REFERER'), + 'billing_email': settings.PAYMENT_SUPPORT_EMAIL, + } + return render_to_response("verify_student/midcourse_reverify_dash.html", context) + + +def toggle_failed_banner_off(request): + """ + Finds all denied midcourse reverifications for a user and permanently toggles + the "Reverification Failed" banner off for those verifications. + """ + user_id = request.POST.get('user_id') + SoftwareSecurePhotoVerification.display_off(user_id) + + @login_required def reverification_submission_confirmation(_request): """ Shows the user a confirmation page if the submission to SoftwareSecure was successful """ - return render_to_response("verify_student/reverification_confirmation.html") + + +@login_required +def midcourse_reverification_confirmation(_request): # pylint: disable=C0103 + """ + Shows the user a confirmation page if the submission to SoftwareSecure was successful + """ + return render_to_response("verify_student/midcourse_reverification_confirmation.html") + + +@login_required +def reverification_window_expired(_request): + """ + Displays an error page if a student tries to submit a reverification, but the window + for that reverification has already expired. + """ + # TODO need someone to review the copy for this template + return render_to_response("verify_student/reverification_window_expired.html") diff --git a/lms/envs/aws.py b/lms/envs/aws.py index 0f1873e688..8014ed8da9 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -139,7 +139,7 @@ EMAIL_USE_TLS = ENV_TOKENS.get('EMAIL_USE_TLS', False) # django default is Fals SITE_NAME = ENV_TOKENS['SITE_NAME'] SESSION_ENGINE = ENV_TOKENS.get('SESSION_ENGINE', SESSION_ENGINE) SESSION_COOKIE_DOMAIN = ENV_TOKENS.get('SESSION_COOKIE_DOMAIN') -REGISTRATION_OPTIONAL_FIELDS = ENV_TOKENS.get('REGISTRATION_OPTIONAL_FIELDS', REGISTRATION_OPTIONAL_FIELDS) +REGISTRATION_EXTRA_FIELDS = ENV_TOKENS.get('REGISTRATION_EXTRA_FIELDS', REGISTRATION_EXTRA_FIELDS) CMS_BASE = ENV_TOKENS.get('CMS_BASE', 'studio.edx.org') @@ -348,6 +348,10 @@ GRADES_DOWNLOAD_ROUTING_KEY = HIGH_MEM_QUEUE GRADES_DOWNLOAD = ENV_TOKENS.get("GRADES_DOWNLOAD", GRADES_DOWNLOAD) +##### 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_ROOT_DIR = ENV_TOKENS.get('MICROSITE_ROOT_DIR') if MICROSITE_CONFIGURATION: @@ -357,3 +361,13 @@ if MICROSITE_CONFIGURATION: VIRTUAL_UNIVERSITIES, 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") diff --git a/lms/envs/bok_choy.auth.json b/lms/envs/bok_choy.auth.json index 858a6e154d..931963c6d0 100644 --- a/lms/envs/bok_choy.auth.json +++ b/lms/envs/bok_choy.auth.json @@ -94,7 +94,7 @@ "password": "password", "peer_grading": "peer_grading", "staff_grading": "staff_grading", - "url": "http://localhost:18060/", + "url": "** OVERRIDDEN **", "username": "lms" }, "SECRET_KEY": "", @@ -107,7 +107,7 @@ "password": "password", "username": "lms" }, - "url": "http://localhost:18040" + "url": "** OVERRIDDEN **" }, "ZENDESK_API_KEY": "", "ZENDESK_USER": "" diff --git a/lms/envs/bok_choy.py b/lms/envs/bok_choy.py index cd4fac9aee..851ae22dd6 100644 --- a/lms/envs/bok_choy.py +++ b/lms/envs/bok_choy.py @@ -38,6 +38,12 @@ MONGO_MODULESTORE['OPTIONS']['fs_root'] = (TEST_ROOT / "data").abspath() XML_MODULESTORE = MODULESTORE['default']['OPTIONS']['stores']['xml'] XML_MODULESTORE['OPTIONS']['data_dir'] = (TEST_ROOT / "data").abspath() +# Configure the LMS to use our stub XQueue implementation +XQUEUE_INTERFACE['url'] = 'http://localhost:8040' + +# Configure the LMS to use our stub ORA implementation +OPEN_ENDED_GRADING_INTERFACE['url'] = 'http://localhost:8041/' + # Enable django-pipeline and staticfiles STATIC_ROOT = (TEST_ROOT / "staticfiles").abspath() PIPELINE = True diff --git a/lms/envs/common.py b/lms/envs/common.py index 32464e04e3..5bc8f27d32 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- """ 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, @@ -203,11 +204,17 @@ FEATURES = { # grades CSV files to S3 and give links for downloads. 'ENABLE_S3_GRADE_DOWNLOADS': False, + # whether to use password policy enforcement or not + 'ENFORCE_PASSWORD_POLICY': False, + # Give course staff unrestricted access to grade downloads (if set to False, # only edX superusers can perform the downloads) 'ALLOW_COURSE_STAFF_GRADE_DOWNLOADS': False, 'ENABLED_PAYMENT_REPORTS': ["refund_report", "itemized_purchase_report", "university_revenue_share", "certificate_status"], + + # Turn off account locking if failed login attempts exceeds a limit + 'ENABLE_MAX_FAILED_LOGIN_ATTEMPTS': False, } # Used for A/B testing @@ -277,7 +284,7 @@ TEMPLATE_CONTEXT_PROCESSORS = ( 'django.core.context_processors.request', 'django.core.context_processors.static', 'django.contrib.messages.context_processors.messages', - #'django.core.context_processors.i18n', + 'django.core.context_processors.i18n', 'django.contrib.auth.context_processors.auth', # this is required for admin 'django.core.context_processors.csrf', @@ -492,14 +499,60 @@ FAVICON_PATH = 'images/favicon.ico' # Locale/Internationalization 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 -LANGUAGES = () -# We want i18n to be turned off in production, at least until we have full localizations. -# Thus we want the Django translation engine to be disabled. Otherwise even without -# 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 +# Sourced from http://www.localeplanet.com/icu/ and wikipedia +LANGUAGES = ( + ('eo', u'Dummy Language (Esperanto)'), # Dummy languaged used for testing + + ('ach', u'Acholi'), # Acoli + ('ar', u'العربية'), # Arabic + ('bg-bg', u'български (България)'), # Bulgarian (Bulgaria) + ('bn', u'বাংলা'), # Bengali + ('bn-bd', u'বাংলা (বাংলাদেশ)'), # Bengali (Bangladesh) + ('cs', u'Čeština'), # Czech + ('cy', u'Cymraeg'), # Welsh + ('de-de', u'Deutsch (Deutschland)'), # German (Germany) + ('en@lolcat', u'LOLCAT English'), # LOLCAT English + ('en@pirate', u'Pirate English'), # Pirate English + ('es-419', u'Español (Latinoamérica)'), # Spanish (Latin America) + ('es-ec', u'Español (Ecuador)'), # Spanish (Ecuador) + ('es-es', u'Español (España)'), # Spanish (Spain) + ('es-mx', u'Español (México)'), # Spanish (Mexico) + ('es-us', u'Español (Estados Unidos)'), # Spanish (United States) + ('et-ee', u'Eesti (Eesti)'), # Estonian (Estonia) + ('fa', u'فارسی'), # Persian + ('fa-ir', u'فارسی (ایران)'), # Persian (Iran) + ('fi-fi', u'Suomi (Suomi)'), # Finnish (Finland) + ('fr', u'Français'), # French + ('gl', u'Galego'), # Galician + ('he', u'עברית'), # Hebrew + ('hi', u'हिन्दी'), # Hindi + ('hy-am', u'Հայերէն (Հայաստանի Հանրապետութիւն)'), # Armenian (Armenia) + ('id', u'Bahasa Indonesia'), # Indonesian + ('it-it', u'Italiano (Italia)'), # Italian (Italy) + ('ja-jp', u'日本語(日本)'), # Japanese (Japan) + ('km-kh', u'ភាសាខ្មែរ (កម្ពុជា)'), # Khmer (Cambodia) + ('ko-kr', u'한국어(대한민국)'), # Korean (Korea) + ('lt-lt', u'Lietuvių (Lietuva)'), # Lithuanian (Lithuania) + ('ml', u'മലയാളം'), # Malayalam + ('nb', u'Norsk bokmål'), # Norwegian Bokmål + ('nl-nl', u'Nederlands (Nederland)'), # Dutch (Netherlands) + ('pl', u'Polski'), # Polish + ('pt-br', u'Português (Brasil)'), # Portuguese (Brazil) + ('pt-pt', u'Português (Portugal)'), # Portuguese (Portugal) + ('ru', u'Русский'), # Russian + ('si', u'සිංහල'), # Sinhala + ('sk', u'Slovenčina'), # Slovak + ('sl', u'Slovenščina'), # Slovenian + ('th', u'ไทย'), # Thai + ('tr-tr', u'Türkçe (Türkiye)'), # Turkish (Turkey) + ('uk', u'Українська'), # Uknranian + ('vi', u'Tiếng Việt'), # Vietnamese + ('zh-cn', u'大陆简体'), # Chinese (China) + ('zh-tw', u'台灣正體'), # Chinese (Taiwan) +) + +USE_I18N = True USE_L10N = True # Localization strings (e.g. django.po) are under this directory @@ -635,11 +688,13 @@ MIDDLEWARE_CLASSES = ( 'django.contrib.messages.middleware.MessageMiddleware', 'track.middleware.TrackMiddleware', - 'edxmako.middleware.MakoMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'course_wiki.course_nav.Middleware', + # Allows us to dark-launch particular languages + 'dark_lang.middleware.DarkLangMiddleware', + # Detects user-requested locale from 'accept-language' header in http request 'django.middleware.locale.LocaleMiddleware', @@ -651,6 +706,8 @@ MIDDLEWARE_CLASSES = ( # catches any uncaught RateLimitExceptions and returns a 403 instead of a 500 'ratelimitbackend.middleware.RateLimitMiddleware', + # needs to run after locale middleware (or anything that modifies the request context) + 'edxmako.middleware.MakoMiddleware', # For A/B testing 'waffle.middleware.WaffleMiddleware', @@ -976,6 +1033,9 @@ INSTALLED_APPS = ( 'djcelery', 'south', + # Database-backed configuration + 'config_models', + # Monitor the status of services 'service_status', @@ -1054,6 +1114,15 @@ INSTALLED_APPS = ( # Student Identity Verification 'verify_student', + + # Dark-launching languages + 'dark_lang', + + # Microsite configuration + 'microsite_configuration', + + # Student Identity Reverification + 'reverification', ) ######################### MARKETING SITE ############################### @@ -1152,14 +1221,21 @@ if FEATURES.get('AUTH_USE_CAS'): ###################### Registration ################################## -# Remove some of the fields from the list to not display them -REGISTRATION_OPTIONAL_FIELDS = set([ - 'level_of_education', - 'gender', - 'year_of_birth', - 'mailing_address', - 'goals', -]) +# For each of the fields, give one of the following values: +# - 'required': to display the field, and make it mandatory +# - 'optional': to display the field, and make it non-mandatory +# - 'hidden': to not display the field + +REGISTRATION_EXTRA_FIELDS = { + 'level_of_education': 'optional', + 'gender': 'optional', + 'year_of_birth': 'optional', + 'mailing_address': 'optional', + 'goals': 'optional', + 'honor_code': 'required', + 'city': 'hidden', + 'country': 'hidden', +} ###################### Grade Downloads ###################### GRADES_DOWNLOAD_ROUTING_KEY = HIGH_MEM_QUEUE @@ -1170,6 +1246,14 @@ GRADES_DOWNLOAD = { 'ROOT_PATH': '/tmp/edx-s3/grades', } +#### PASSWORD POLICY SETTINGS ##### + +PASSWORD_MIN_LENGTH = None +PASSWORD_MAX_LENGTH = None +PASSWORD_COMPLEXITY = {} +PASSWORD_DICTIONARY_EDIT_DISTANCE_THRESHOLD = None +PASSWORD_DICTIONARY = [] + ##################### LinkedIn ##################### INSTALLED_APPS += ('django_openid_auth',) @@ -1180,3 +1264,7 @@ LINKEDIN_API = { 'EMAIL_WHITELIST': [], 'COMPANY_ID': '2746406', } + +##### ACCOUNT LOCKOUT DEFAULT PARAMETERS ##### +MAX_FAILED_LOGIN_ATTEMPTS_ALLOWED = 5 +MAX_FAILED_LOGIN_ATTEMPTS_LOCKOUT_PERIOD_SECS = 15 * 60 diff --git a/lms/envs/dev.py b/lms/envs/dev.py index 8f40887433..fc486726bf 100644 --- a/lms/envs/dev.py +++ b/lms/envs/dev.py @@ -16,11 +16,6 @@ from .common import * from logsettings import get_logger_config DEBUG = True -USE_I18N = True -# For displaying the dummy text, we need to provide a language mapping. -LANGUAGES = ( - ('eo', 'Esperanto'), -) TEMPLATE_DEBUG = True FEATURES['DISABLE_START_DATES'] = False diff --git a/lms/envs/test.py b/lms/envs/test.py index a3b490c0b4..48782bf46e 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -73,6 +73,7 @@ COMMON_TEST_DATA_ROOT = COMMON_ROOT / "test" / "data" # Where the content data is checked out. This may not exist on jenkins. GITHUB_REPO_ROOT = ENV_ROOT / "data" +USE_I18N = True XQUEUE_INTERFACE = { "url": "http://sandbox-xqueue.edx.org", diff --git a/lms/lib/comment_client/thread.py b/lms/lib/comment_client/thread.py index c44ae9b5dd..768cc3aa1b 100644 --- a/lms/lib/comment_client/thread.py +++ b/lms/lib/comment_client/thread.py @@ -74,10 +74,9 @@ class Thread(models.Model): 'recursive': kwargs.get('recursive'), 'user_id': kwargs.get('user_id'), 'mark_as_read': kwargs.get('mark_as_read', True), + 'resp_skip': kwargs.get('response_skip'), + 'resp_limit': kwargs.get('response_limit'), } - - # user_id may be none, in which case it shouldn't be part of the - # request. request_params = strip_none(request_params) response = perform_request('get', url, request_params) diff --git a/lms/lib/xblock/runtime.py b/lms/lib/xblock/runtime.py index cb2c033025..74066a6af1 100644 --- a/lms/lib/xblock/runtime.py +++ b/lms/lib/xblock/runtime.py @@ -35,7 +35,7 @@ def quote_slashes(text): ';;'. By making the escape sequence fixed length, and escaping identifier character ';', we are able to reverse the escaping. """ - return re.sub(r'[;/]', _quote_slashes, text) + return re.sub(ur'[;/]', _quote_slashes, text) def _unquote_slashes(match): @@ -84,7 +84,7 @@ def handler_url(course_id, block, handler, suffix='', query='', thirdparty=False url = reverse(view_name, kwargs={ 'course_id': course_id, - '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, 'suffix': suffix, }) diff --git a/lms/static/coffee/src/instructor_dashboard/membership.coffee b/lms/static/coffee/src/instructor_dashboard/membership.coffee index 590fd86f3e..0507b1d963 100644 --- a/lms/static/coffee/src/instructor_dashboard/membership.coffee +++ b/lms/static/coffee/src/instructor_dashboard/membership.coffee @@ -28,25 +28,10 @@ class MemberListWidget template_html = $("#member-list-widget-template").html() @$container.html Mustache.render template_html, params - # bind info toggle - @$('.info-badge').click => @toggle_info() - # bind add button @$('input[type="button"].add').click => params.add_handler? @$('.add-field').val() - show_info: -> - @$('.info').show() - @$('.member-list').hide() - - show_list: -> - @$('.info').hide() - @$('.member-list').show() - - toggle_info: -> - @$('.info').toggle() - @$('.member-list').toggle() - # clear the input text field clear_input: -> @$('.add-field').val '' @@ -102,8 +87,6 @@ class AuthListWidget extends MemberListWidget @clear_errors() @clear_input() @reload_list() - @$('.info').hide() - @$('.member-list').show() # handle clicks on the add button add_handler: (input) -> @@ -120,15 +103,12 @@ class AuthListWidget extends MemberListWidget # reload the list of members reload_list: -> # @clear_rows() - # @show_info() @get_member_list (error, member_list) => # abort on error return @show_errors error unless error is null # only show the list of there are members @clear_rows() - @show_info() - # @show_info() # use _.each instead of 'for' so that member # is bound in the button callback. @@ -145,8 +125,6 @@ class AuthListWidget extends MemberListWidget @clear_errors() @reload_list() @add_row [member.username, member.email, $revoke_btn] - # make sure the list is shown because there are members. - @show_list() # clear error display clear_errors: -> @$error_section?.text '' diff --git a/lms/static/js/verify_student/photocapture.js b/lms/static/js/verify_student/photocapture.js index 4db15e9d51..f8c89ae0db 100644 --- a/lms/static/js/verify_student/photocapture.js +++ b/lms/static/js/verify_student/photocapture.js @@ -35,15 +35,22 @@ var submitReverificationPhotos = function() { } +var submitMidcourseReverificationPhotos = function() { + $('').attr({ + type: 'hidden', + name: 'face_image', + value: $("#face_image")[0].src, + }).appendTo("#reverify_form"); + $("#reverify_form").submit(); +} + var submitToPaymentProcessing = function() { var contribution_input = $("input[name='contribution']:checked") var contribution = 0; - if(contribution_input.attr('id') == 'contribution-other') - { + if(contribution_input.attr('id') == 'contribution-other') { contribution = $("input[name='contribution-other-amt']").val(); } - else - { + else { contribution = contribution_input.val(); } var course_id = $("input[name='course_id']").val(); @@ -276,11 +283,16 @@ $(document).ready(function() { submitReverificationPhotos(); }); + $("#midcourse_reverify_button").click(function() { + submitMidcourseReverificationPhotos(); + }); + // prevent browsers from keeping this button checked $("#confirm_pics_good").prop("checked", false) $("#confirm_pics_good").change(function() { $("#pay_button").toggleClass('disabled'); $("#reverify_button").toggleClass('disabled'); + $("#midcourse_reverify_button").toggleClass('disabled'); }); diff --git a/lms/static/maintenance/index.html b/lms/static/maintenance/index.html index eeaa7b7b0b..574ad1dbae 100644 --- a/lms/static/maintenance/index.html +++ b/lms/static/maintenance/index.html @@ -1,5 +1,5 @@ - + diff --git a/lms/static/sass/_discussion.scss b/lms/static/sass/_discussion.scss index 5bac0d1ee9..b7539b9668 100644 --- a/lms/static/sass/_discussion.scss +++ b/lms/static/sass/_discussion.scss @@ -1186,6 +1186,8 @@ body.discussion { .blank-slate { + padding: 40px 40px 10px; + section { border-bottom: 1px solid #ccc; } @@ -1294,22 +1296,30 @@ body.discussion { } .row-item-full { - - .email-setting { + + .notification-checkbox { display: inline-block; - text-align: center; - vertical-align: middle; - margin-left: $baseline/2; + padding: $baseline/4 0 $baseline/2 0; + margin-right: $baseline/2; + border-radius: 5px; + border: 1px solid gray; + + .email-setting { + display: inline-block; + text-align: center; + vertical-align: middle; + margin-left: $baseline/2; + } + + .icon { + display: inline-block; + } + + .email-setting:checked ~ .icon { + color: $green; + } } - - .icon { - display: inline-block; - } - - .email-setting:checked ~ .icon { - color: $green; - } - + .row-description { display: inline-block; width:80%; @@ -1336,7 +1346,6 @@ body.discussion { .discussion-article { position: relative; - padding: $baseline*2; min-height: 468px; a { @@ -1397,7 +1406,8 @@ body.discussion { } .discussion-post { - padding: $baseline/2 $baseline; + padding: $baseline*2 $baseline*2 $baseline $baseline*2; + box-shadow: 0 1px 3px $shadow; > header .vote-btn { position: relative; @@ -1443,7 +1453,7 @@ body.discussion { .responses { list-style: none; margin-top: $baseline; - padding: 0; + padding: 0px $baseline*2; > li { position: relative; @@ -1511,6 +1521,27 @@ body.discussion { } } + div.add-response { + margin-top: $baseline; + padding: 0px 30px; + + button.add-response-btn { + @include white-button; + position: relative; + padding: 0px 30px; + border: 1px solid #b2b2b2; + box-shadow: 0 1px 3px rgba(0, 0, 0, .15); + font-size: 13px; + text-align: left; + @include animation(fadeIn .3s); + width: 100%; + + span.add-response-btn-text { + padding-left: 4px; + } + } + } + .vote-btn { position: relative; z-index: 100; @@ -1783,7 +1814,7 @@ body.discussion { .discussion-reply-new { - padding: $baseline; + padding: 0.5*$baseline 30px $baseline; @include clearfix; @include transition(opacity .2s linear 0s); @@ -1856,11 +1887,31 @@ body.discussion { margin-top: $baseline*2; > li { - margin: 0 $baseline $baseline !important; padding: 26px 30px $baseline !important; } } + div.add-response.post-extended-content { + margin-top: $baseline; + margin-bottom: 20px; + + button.add-response-btn { + @include white-button; + position: relative; + border: 1px solid #b2b2b2; + box-shadow: 0 1px 3px rgba(0, 0, 0, .15); + font-size: 13px; + text-align: left; + @include animation(fadeIn .3s); + width: 100%; + padding-left: 30px; + + span.add-response-btn-text { + padding-left: 4px; + } + } + } + .loading-animation { background-image: url(../images/spinner-on-grey.gif); } @@ -1938,7 +1989,7 @@ body.discussion { @include transition(all .2s linear 0s); .discussion-post { - padding: 12px $baseline 0 $baseline; + padding: 12px 30px 0px; @include clearfix; .inline-comment-count { @@ -2522,3 +2573,32 @@ display:none; color: #333; font-style: italic; } + +.response-count { + margin-top: $baseline; + padding: 0px 3*$baseline; +} + +.response-pagination { + padding: 0px 1.5*$baseline; + + .response-display-count { + display: block; + padding: 0.5*$baseline 1.5*$baseline; + } + + .load-response-button { + display: block; + @include white-button; + font: normal 1em/1.6em $sans-serif; + position: relative; + padding: 0px 1.5*$baseline; + margin: $baseline/2 0px; + border: 1px solid #b2b2b2; + box-shadow: 0 1px 3px rgba(0, 0, 0, .15); + font-size: 13px; + text-align: left; + @include animation(fadeIn .3s); + width: 100%; + } +} diff --git a/lms/static/sass/application-extend1.scss.mako b/lms/static/sass/application-extend1.scss.mako index 310abf9f2d..cb993e14b6 100644 --- a/lms/static/sass/application-extend1.scss.mako +++ b/lms/static/sass/application-extend1.scss.mako @@ -12,8 +12,8 @@ // base - utilities @import 'base/reset'; -@import 'base/mixins'; @import 'base/variables'; +@import 'base/mixins'; ## THEMING ## ------- diff --git a/lms/static/sass/application-extend2.scss.mako b/lms/static/sass/application-extend2.scss.mako index 9473a41e4a..cfb6a1b698 100644 --- a/lms/static/sass/application-extend2.scss.mako +++ b/lms/static/sass/application-extend2.scss.mako @@ -12,8 +12,8 @@ // base - utilities @import 'base/reset'; -@import 'base/mixins'; @import 'base/variables'; +@import 'base/mixins'; ## THEMING ## ------- @@ -41,6 +41,7 @@ // base - elements @import 'elements/typography'; @import 'elements/controls'; +@import 'elements/system-feedback'; // base - specific views @import 'views/verification'; diff --git a/lms/static/sass/application.scss.mako b/lms/static/sass/application.scss.mako index 7d6da444ce..5365ec52ac 100644 --- a/lms/static/sass/application.scss.mako +++ b/lms/static/sass/application.scss.mako @@ -11,8 +11,8 @@ // base - utilities @import 'base/reset'; -@import 'base/mixins'; @import 'base/variables'; +@import 'base/mixins'; ## THEMING ## ------- diff --git a/lms/static/sass/base/_mixins.scss b/lms/static/sass/base/_mixins.scss index 5a5a4fde94..22a1d96dbd 100644 --- a/lms/static/sass/base/_mixins.scss +++ b/lms/static/sass/base/_mixins.scss @@ -54,6 +54,30 @@ // ==================== + +// extends - UI - used for page/view-level wrappers (for centering/grids) +%ui-wrapper { + @include clearfix(); + @include box-sizing(border-box); + width: 100%; +} + +// extends - UI - window +%ui-window { + @include clearfix(); + border-radius: 3px; + box-shadow: 0 1px 2px 1px $shadow-l1; + margin-bottom: $baseline; + border: 1px solid $light-gray; + background: $white; +} + +// extends - UI archetypes - well +%ui-well { + box-shadow: inset 0 1px 2px 1px $shadow-l1; + padding: ($baseline*0.75) $baseline; +} + // extends - UI - visual link %ui-fake-link { cursor: pointer; diff --git a/lms/static/sass/base/_variables.scss b/lms/static/sass/base/_variables.scss index c1bbcd5d6f..98a45a2281 100644 --- a/lms/static/sass/base/_variables.scss +++ b/lms/static/sass/base/_variables.scss @@ -224,6 +224,15 @@ $error-color: $error-red; $warning-color: $m-pink; $confirm-color: $m-green; +// Notifications +$notify-banner-bg-1: rgb(56,56,56); +$notify-banner-bg-2: rgb(136,136,136); +$notify-banner-bg-3: rgb(223,223,223); + +$alert-color: rgb(212, 64, 64); //rich red +$warning-color: rgb(237, 189, 60); //rich yellow +$success-color: rgb(37, 184, 90); //rich green + // ==================== // MISC: visual horizontal rules @@ -285,6 +294,13 @@ $footer_margin: ($baseline/4) 0 ($baseline*1.5) 0; // ==================== +// VIEWS: homepage +$homepage__header--gradient__color--alpha: lighten($blue, 15%); +$homepage__header--gradient__color--bravo: saturate($blue, 30%); +$homepage__header--background: lighten($blue, 15%); + +// ==================== + // IMAGES: backgrounds $homepage-bg-image: '../images/homepage-bg.jpg'; @@ -301,3 +317,8 @@ $video-thumb-url: '../images/courses/video-thumb.jpg'; $f-serif: 'Bree Serif', Georgia, Cambria, 'Times New Roman', Times, serif; $f-sans-serif: 'Open Sans','Helvetica Neue', Helvetica, Arial, sans-serif; $f-monospace: 'Bitstream Vera Sans Mono', Consolas, Courier, monospace; + +// SPLINT: colors + +$msg-bg: $action-primary-bg; + diff --git a/lms/static/sass/course.scss.mako b/lms/static/sass/course.scss.mako index 4bd8cfe4bd..2cdd1c2031 100644 --- a/lms/static/sass/course.scss.mako +++ b/lms/static/sass/course.scss.mako @@ -2,8 +2,8 @@ @import 'base/reset'; @import 'base/font_face'; -@import 'base/mixins'; @import 'base/variables'; +@import 'base/mixins'; ## THEMING ## ------- diff --git a/lms/static/sass/course/instructor/_instructor_2.scss b/lms/static/sass/course/instructor/_instructor_2.scss index 2f8eb8947e..03edd97d03 100644 --- a/lms/static/sass/course/instructor/_instructor_2.scss +++ b/lms/static/sass/course/instructor/_instructor_2.scss @@ -526,30 +526,15 @@ section.instructor-dashboard-content-2 { font-size: $body-font-size * 4/5; } - .info-badge { - // float: right; - position: absolute; - top: $baseline / 2; - right: $baseline / 2; - width: 17px; - height: 17px; - background: url('../images/info-icon-dark.png') left center no-repeat; - opacity: 0.35; - &:hover, &:focus { opacity: 0.45; } - &:active { opacity: 0.5; } - } - .info { - display: none; - @include box-sizing(border-box); max-height: $content-height; padding: $baseline; border: 1px solid $light-gray; - border-top: none; color: $lighter-base-font-color; line-height: 1.3em; + font-size: .85em; } .member-list { diff --git a/lms/static/sass/elements/_system-feedback.scss b/lms/static/sass/elements/_system-feedback.scss new file mode 100644 index 0000000000..5b041d0a56 --- /dev/null +++ b/lms/static/sass/elements/_system-feedback.scss @@ -0,0 +1,143 @@ +// lms - elements - system feedback +// ==================== + +// messages + +// UI : message +.wrapper-msg { + display: block; + margin-bottom: ($baseline/4); + box-shadow: 0 0 5px $shadow-d1 inset; + background: $notify-banner-bg-1; + padding: $baseline ($baseline*1.5); + + &.is-hidden { + display: none; + } + + // basic object + .msg { + @include clearfix(); + max-width: grid-width(12); + min-width: 760px; + width: flex-grid(12); + margin: 0 auto; + } + + .msg-content, + .msg-icon { + display: inline-block; + vertical-align: middle; + } + + .msg-content { + + .title { + @extend %t-title5; + @extend %t-weight4; + margin-bottom: ($baseline/4); + color: inherit; + text-transform: none; + letter-spacing: 0; + } + + .copy { + @extend %t-copy-sub1; + color: inherit; + + p { // nasty reset + @extend %t-copy-sub1; + color: inherit; + } + } + } + + .has-actions { + + .msg-content { + width: flex-grid(10,12); + } + + .nav-actions { + width: flex-grid(2,12); + display: inline-block; + vertical-align: middle; + text-align: right; + + .action-primary { + @extend %btn-primary-green; + } + } + } + + .is-dismissable { + + .msg-content { + width: flex-grid(11,12); + } + + .action-dismiss { + width: flex-grid(1,12); + display: inline-block; + vertical-align: top; + text-align: right; + + .button-dismiss { //ugly reset on button element + @extend %t-icon4; + background: none; + box-shadow: none; + border: none; + text-shadow: none; + color: inherit; + + &:hover { + color: $action-primary-bg; + } + } + } + } + + // object variations + &.urgency-high { + background: $notify-banner-bg-1; + + .msg { + color: $white; + } + } + + &.urgency-mid { + background: $notify-banner-bg-2; + + .msg { + color: $white; + } + } + + &.urgency-low { + background: $notify-banner-bg-3; + + .msg { + color: $black; + } + } + + &.alert { + border-top: 3px solid $alert-color; + } + + &.warning { + border-top: 3px solid $warning-color; + } + + &.success { + border-top: 3px solid $success-color; + } +} + + +// prompts + +// notifications + +// alerts diff --git a/lms/static/sass/multicourse/_dashboard.scss b/lms/static/sass/multicourse/_dashboard.scss index 6d6a782b73..ef864cb392 100644 --- a/lms/static/sass/multicourse/_dashboard.scss +++ b/lms/static/sass/multicourse/_dashboard.scss @@ -133,6 +133,52 @@ } } } + + .reverify-status-list { + padding: 0 0 0 ($baseline/2); + margin: ($baseline/4) 0; + + .status-item { + @extend %t-copy-sub2; + margin-bottom: 7px; + border-bottom: 0; + padding: 0; + + [class^="icon-"] { + display: inline-block; + vertical-align: top; + margin: ($baseline/10) ($baseline/4) 0 0; + } + + &.is-open [class^="icon-"] { + color: $action-primary-bg; + } + + &.is-pending [class^="icon-"] { + color: $warning-color; + } + + &.is-approved [class^="icon-"] { + color: $success-color; + } + + &.is-denied [class^="icon-"] { + color: $alert-color; + } + + .label { + @extend %text-sr; + } + + .course-name { + @include line-height(12); + display: inline-block; + vertical-align: top; + width: 80%; + color: inherit; + } + } + } } .news-carousel { diff --git a/lms/static/sass/multicourse/_home.scss b/lms/static/sass/multicourse/_home.scss index 347a9a915c..9a2c4e6883 100644 --- a/lms/static/sass/multicourse/_home.scss +++ b/lms/static/sass/multicourse/_home.scss @@ -7,8 +7,7 @@ } > header { - background: $dashboard-profile-color; - @include background-image(url($homepage-bg-image)); + @include linear-gradient($homepage__header--gradient__color--alpha, $homepage__header--gradient__color--bravo); background-size: cover; border-bottom: 1px solid $border-color-3; box-shadow: 0 1px 0 0 $course-header-bg, inset 0 -1px 5px 0 rgba(0,0,0, 0.1); diff --git a/lms/static/sass/views/_verification.scss b/lms/static/sass/views/_verification.scss index 9bd1274928..fe337c556a 100644 --- a/lms/static/sass/views/_verification.scss +++ b/lms/static/sass/views/_verification.scss @@ -1,87 +1,6 @@ // lms - views - verification flow // ==================== -// MISC: extends - type -// application: canned headings -%hd-lv1 { - @extend %t-title1; - @extend %t-weight1; - color: $m-gray-d4; - margin: 0 0 ($baseline*2) 0; -} - -%hd-lv2 { - @extend %t-title4; - @extend %t-weight1; - margin: 0 0 ($baseline*0.75) 0; - border-bottom: 1px solid $m-gray-l4; - padding-bottom: ($baseline/2); - color: $m-gray-d4; -} - -%hd-lv3 { - @extend %t-title6; - @extend %t-weight4; - margin: 0 0 ($baseline/4) 0; - color: $m-gray-d4; -} - -%hd-lv4 { - @extend %t-title6; - @extend %t-weight2; - margin: 0 0 $baseline 0; - color: $m-gray-d4; -} - -%hd-lv5 { - @extend %t-title7; - @extend %t-weight4; - margin: 0 0 ($baseline/4) 0; - color: $m-gray-d4; -} - -// application: canned copy -%copy-base { - @extend %t-copy-base; - color: $m-gray-d2; -} - -%copy-lead1 { - @extend %t-copy-lead2; - color: $m-gray; -} - -%copy-detail { - @extend %t-copy-sub1; - @extend %t-weight3; - color: $m-gray-d1; -} - -%copy-metadata { - @extend %t-copy-sub2; - color: $m-gray-d1; - - - %copy-metadata-value { - @extend %t-weight2; - } - - %copy-metadata-value { - @extend %t-weight4; - } -} - -// application: canned links -%copy-link { - border-bottom: 1px dotted transparent; - - &:hover, &:active { - border-color: $link-color-d1; - } -} - -// ==================== - // MISC: extends - button %btn-verify-primary { @extend %btn-primary-green; @@ -89,26 +8,6 @@ // ==================== -// MISC: extends - UI - window -%ui-window { - @include clearfix(); - border-radius: ($baseline/10); - box-shadow: 0 1px 2px 1px $shadow-l1; - margin-bottom: $baseline; - border: 1px solid $m-gray-l3; - background: $white; -} - -// ==================== - -// MISC: extends - UI - well -%ui-well { - box-shadow: inset 0 1px 2px 1px $shadow-l1; - padding: ($baseline*0.75) $baseline; -} - -// ==================== - // MISC: expandable UI .is-expandable { @@ -153,7 +52,8 @@ // ==================== // VIEW: all verification steps -.verification-process { +.verification-process, +.midcourse-reverification-process { // reset: box-sizing (making things so right its scary) * { @@ -1894,7 +1794,477 @@ } } } + + // VIEW: midcourse re-verification + &.midcourse-reverification-process { + + // step-dash + + &.step-dash { + + .content-main > .title { + @extend %t-title7; + display: block; + font-weight: 600; + color: $m-gray; + } + + .wrapper-reverify-open, + .wrapper-reverify-status { + display: inline-block; + vertical-align: top; + width: 48%; + } + + .copy .title { + @extend %t-title6; + font-weight: 600; + } + + .wrapper-reverify-status .title { + @extend %t-title6; + font-weight: normal; + color: $m-gray; + } + + .action-reverify { + padding: ($baseline/2) ($baseline*0.75); + } + + .reverification-list { + margin-right: ($baseline*1.5); + padding: 0; + list-style-type: none; + + .item { + box-shadow: 0 2px 5px 0 $shadow-l1 inset; + margin: ($baseline*.75) ($baseline*.75) ($baseline*.75) 0; + border: 1px solid $m-gray-t2; + + &.complete { + border: 1px solid $success-color; + + .course-info { + opacity: .5; + + .course-name { + font-weight: normal; + } + } + + .reverify-status { + @extend %t-weight4; + border-top: 1px solid $light-gray; + background-color: $m-gray-l4; + color: $success-color; + } + } + + &.pending { + border: 1px solid $warning-color; + + .course-info { + opacity: .5; + + .course-name { + font-weight: normal; + } + } + + .reverify-status { + @extend %t-weight4; + border-top: 1px solid $light-gray; + background-color: $m-gray-l4; + color: $warning-color; + } + } + + &.failed { + border: 1px solid $alert-color; + + .course-info { + opacity: .5; + + .course-name { + font-weight: normal; + } + } + + .reverify-status { + @extend %t-weight4; + border-top: 1px solid $light-gray; + background-color: $m-gray-l4; + color: $alert-color; + } + } + } + + .course-info { + margin-bottom: ($baseline/2); + padding: ($baseline/2) ($baseline*.75); + } + + .course-name { + @extend %t-title5; + display: block; + font-weight: bold; + } + + .deadline { + @extend %copy-detail; + display: block; + margin-top: ($baseline/4); + } + + .reverify-status { + background-color: $light-gray; + padding: ($baseline/2) ($baseline*.75); + } + } + + .support { + margin-top: $baseline; + @extend %t-copy-sub1; + } + + .wrapper-reverification-help { + margin-top: $baseline; + border-top: 1px solid $light-gray; + padding-top: ($baseline*1.5); + + .faq-item { + display: inline-block; + vertical-align: top; + width: flex-grid(4,12); + padding-right: $baseline; + + &:last-child { + padding-right: 0; + } + + .faq-answer { + @extend %t-copy-sub1; + } + } + } + } + + // step-photos + &.step-photos { + + .block-photo .title { + @extend %t-title4; + color: $m-blue-d1; + } + + .wrapper-task { + @include clearfix(); + width: flex-grid(12,12); + margin: $baseline 0; + + .wrapper-help { + float: right; + width: flex-grid(6,12); + padding: 0 $baseline; + + .help { + margin-bottom: ($baseline*1.5); + + &:last-child { + margin-bottom: 0; + } + + .title { + @extend %hd-lv3; + } + + .copy { + @extend %copy-detail; + } + + .example { + color: $m-gray-l2; + } + + // help - general list + .list-help { + margin-top: ($baseline/2); + color: $black; + + .help-item { + margin-bottom: ($baseline/4); + border-bottom: 1px solid $m-gray-l4; + padding-bottom: ($baseline/4); + + &:last-child { + margin-bottom: 0; + border-bottom: none; + padding-bottom: 0; + } + } + + .help-item-emphasis { + @extend %t-weight4; + } + } + + // help - faq + .list-faq { + margin-bottom: $baseline; + } + } + } + + .task { + @extend %ui-window; + float: left; + width: flex-grid(6,12); + margin-right: flex-gutter(); + } + + .controls { + padding: ($baseline*0.75) $baseline; + background: $m-gray-l4; + + .list-controls { + position: relative; + } + + .control { + position: absolute; + + .action { + @extend %btn-primary-blue; + padding: ($baseline/2) ($baseline*0.75); + + *[class^="icon-"] { + @extend %t-icon4; + padding: ($baseline*.25) ($baseline*.5); + display: block; + } + } + + // STATE: hidden + &.is-hidden { + visibility: hidden; + } + + // STATE: shown + &.is-shown { + visibility: visible; + } + + // STATE: approved + &.approved { + + .action { + @extend %btn-verify-primary; + padding: ($baseline/2) ($baseline*0.75); + } + } + } + + // control - redo + .control-redo { + position: absolute; + left: ($baseline/2); + } + + // control - take/do + .control-do { + left: 45%; + } + + // control - approve + .control-approve { + position: absolute; + right: ($baseline/2); + } + } + + .msg { + @include clearfix(); + margin-top: ($baseline*2); + + .copy { + float: left; + width: flex-grid(8,12); + margin-right: flex-gutter(); + } + + .list-actions { + position: relative; + top: -($baseline/2); + float: left; + width: flex-grid(4,12); + text-align: right; + + .action-retakephotos a { + @extend %btn-primary-blue; + @include font-size(14); + padding: ($baseline/2) ($baseline*.75); + } + } + } + + .msg-followup { + border-top: ($baseline/10) solid $m-gray-t0; + padding-top: $baseline; + } + } + + .review-task { + margin-bottom: ($baseline*1.5); + padding: ($baseline*0.75) $baseline; + border-radius: ($baseline/10); + background: $m-gray-l4; + + &:last-child { + margin-bottom: 0; + } + + > .title { + @extend %hd-lv3; + } + + .copy { + @extend %copy-base; + + strong { + @extend %t-weight5; + color: $m-gray-d4; + } + } + } + + + // individual task - name + .review-task-name { + @include clearfix(); + border: 1px solid $light-gray; + + .copy { + float: left; + width: flex-grid(8,12); + margin-right: flex-gutter(); + } + + .list-actions { + position: relative; + top: -($baseline); + float: left; + width: flex-grid(4,12); + text-align: right; + + .action-editname a { + @extend %btn-primary-blue; + @include font-size(14); + padding: ($baseline/2) ($baseline*.75); + } + } + } + + .nav-wizard { + padding: ($baseline*.75) $baseline; + + .prompt-verify { + float: left; + width: flex-grid(6,12); + margin: 0 flex-gutter() 0 0; + + .title { + @extend %hd-lv4; + margin-bottom: ($baseline/4); + } + + .copy { + @extend %t-copy-sub1; + @extend %t-weight3; + } + + .list-actions { + margin-top: ($baseline/2); + } + + .action-verify label { + @extend %t-copy-sub1; + } + } + + .wizard-steps { + margin-top: ($baseline/2); + + .wizard-step { + margin-right: flex-gutter(); + display: inline-block; + vertical-align: middle; + + &:last-child { + margin-right: 0; + } + } + } + } + + + .modal { + + fieldset { + margin-top: $baseline; + } + + .close-modal { + @include font-size(24); + color: $m-blue-d3; + + &:hover, &:focus { + color: $m-blue-d1; + border: none; + } + } + } + + } + } + + &.step-confirmation { + + .instruction { + display: inline-block; + width: flex-grid(8,12); + vertical-align: top; + } + + .actions-next { + display: inline-block; + width: flex-grid(4,12); + vertical-align: top; + margin-top: $baseline; + } + + .nav-item { + display: block; + margin: 0 0 $baseline 0; + text-align: center; + + &.conditional:after { + content: "or"; + display: block; + margin: $baseline 0; + } + } + + } } + + +//reverify notification special styles +.msg-reverify { + .reverify-list { + margin: ($baseline/4) 0; + } +} + // ==================== // STATE: already verified diff --git a/lms/templates/course_groups/debug.html b/lms/templates/course_groups/debug.html index 7554557f81..a70d4151ee 100644 --- a/lms/templates/course_groups/debug.html +++ b/lms/templates/course_groups/debug.html @@ -1,9 +1,9 @@ <%! from django.utils.translation import ugettext as _ %> - + ## "edX" should not be translated - <%block name="title">edX + <%block name="pagetitle"> diff --git a/lms/templates/courseware/course_about.html b/lms/templates/courseware/course_about.html index 44143ad03b..df2989d024 100644 --- a/lms/templates/courseware/course_about.html +++ b/lms/templates/courseware/course_about.html @@ -26,6 +26,11 @@ %> <%include file="${google_analytics_file}" /> + + ## OG (Open Graph) title and description added below to give social media info to display + ## (https://developers.facebook.com/docs/opengraph/howtos/maximizing-distribution-media-content#tags) + + <%block name="js_extra"> @@ -113,7 +118,7 @@ -<%block name="title">${_("About {course.display_number_with_default}").format(course=course) | h} +<%block name="pagetitle">${_("About {course.display_number_with_default}").format(course=course) | h}
diff --git a/lms/templates/courseware/courses.html b/lms/templates/courseware/courses.html index 9351980336..d84c691ab3 100644 --- a/lms/templates/courseware/courses.html +++ b/lms/templates/courseware/courses.html @@ -3,7 +3,7 @@ <%namespace name='static' file='../static_content.html'/> -<%block name="title">${_("Courses")} +<%block name="pagetitle">${_("Courses")} <%! from microsite_configuration.middleware import MicrositeConfiguration %>
diff --git a/lms/templates/courseware/courseware-error.html b/lms/templates/courseware/courseware-error.html index 71744259a6..843c7122ab 100644 --- a/lms/templates/courseware/courseware-error.html +++ b/lms/templates/courseware/courseware-error.html @@ -2,7 +2,7 @@ <%inherit file="/main.html" /> <%namespace name='static' file='../static_content.html'/> <%block name="bodyclass">courseware -<%block name="title">${_("Courseware")} - ${settings.PLATFORM_NAME} +<%block name="pagetitle">${_("Courseware")} <%block name="headextra"> <%static:css group='style-course-vendor'/> diff --git a/lms/templates/courseware/courseware.html b/lms/templates/courseware/courseware.html index 9a6a197de0..a4fb15a69f 100644 --- a/lms/templates/courseware/courseware.html +++ b/lms/templates/courseware/courseware.html @@ -1,8 +1,19 @@ <%! from django.utils.translation import ugettext as _ %> +<%! from microsite_configuration import page_title_breadcrumbs %> <%inherit file="/main.html" /> <%namespace name='static' file='/static_content.html'/> +<%def name="course_name()"> + <% return _("{course_number} Courseware").format(course_number=course.display_number_with_default) %> + + <%block name="bodyclass">courseware ${course.css_class} -<%block name="title">${_("{course_number} Courseware").format(course_number=course.display_number_with_default) | h} +<%block name="title"> + % if section_title: +${page_title_breadcrumbs(section_title, course_name())} + % else: +${page_title_breadcrumbs(course_name())} + %endif + <%block name="headextra"> <%static:css group='style-course-vendor'/> @@ -170,6 +181,7 @@ ${fragment.foot_html()} % endif % if accordion: + <%include file="/dashboard/_dashboard_prompt_midcourse_reverify.html" /> <%include file="/courseware/course_navigation.html" args="active_page='courseware'" /> % endif diff --git a/lms/templates/courseware/info.html b/lms/templates/courseware/info.html index 5c2220010a..612ac5c08b 100644 --- a/lms/templates/courseware/info.html +++ b/lms/templates/courseware/info.html @@ -1,18 +1,21 @@ <%! from django.utils.translation import ugettext as _ %> +<%! from courseware.courses import get_course_info_section %> + <%inherit file="/main.html" /> -<%block name="bodyclass">${course.css_class} <%namespace name='static' file='/static_content.html'/> +<%block name="pagetitle">${__("{course_number} Course Info").format(course_number=course.display_number_with_default)} + <%block name="headextra"> <%static:css group='style-course-vendor'/> <%static:css group='style-course'/> -<%block name="title">${_("{course.display_number_with_default} Course Info").format(course=course) | h} +<%block name="title">${_("{course_number} Course Info").format(course_number=course.display_number_with_default)} + +<%include file="/dashboard/_dashboard_prompt_midcourse_reverify.html" /> + <%include file="/courseware/course_navigation.html" args="active_page='info'" /> -<%! - from courseware.courses import get_course_info_section -%> <%block name="js_extra"> @@ -23,6 +26,7 @@ $(document).ready(function(){ +<%block name="bodyclass">${course.css_class}
% if user.is_authenticated(): diff --git a/lms/templates/courseware/instructor_dashboard.html b/lms/templates/courseware/instructor_dashboard.html index 1ace8daaee..cd5cf0987f 100644 --- a/lms/templates/courseware/instructor_dashboard.html +++ b/lms/templates/courseware/instructor_dashboard.html @@ -1,8 +1,10 @@ <%! from django.utils.translation import ugettext as _ %> -<%inherit file="/main.html" /> <%! from django.core.urlresolvers import reverse %> + +<%inherit file="../main.html" /> <%namespace name='static' file='/static_content.html'/> +<%block name="pagetitle">${_("Instructor Dashboard")} <%block name="headextra"> <%static:css group='style-course-vendor'/> <%static:css group='style-course'/> @@ -392,6 +394,14 @@ function goto( mode) %else:

${_("User requires forum administrator privileges to perform administration tasks. See instructor.")}

%endif + +
+

${_("Explanation of Roles:")}

+

${_("Forum Moderators: can edit or delete any post, remove misuse flags, close and re-open threads, endorse " + "responses, and see posts from all cohorts (if the course is cohorted). Moderators' posts are marked as 'staff'.")}

+

${_("Forum Admins: have moderator privileges, as well as the ability to edit the list of forum moderators " + "(e.g. to appoint a new moderator). Admins' posts are marked as 'staff'.")}

+

${_("Community TAs: have forum moderator privileges, and their posts are labelled 'Community TA'.")}

%endif ##----------------------------------------------------------------------------- diff --git a/lms/templates/courseware/mktg_coming_soon.html b/lms/templates/courseware/mktg_coming_soon.html index 0c90362019..78cf52d88d 100644 --- a/lms/templates/courseware/mktg_coming_soon.html +++ b/lms/templates/courseware/mktg_coming_soon.html @@ -8,7 +8,7 @@ <%inherit file="../mktg_iframe.html" /> -<%block name="title">${_("About {course_id}").format(course_id=course_id)} +<%block name="pagetitle">${_("About {course_id}").format(course_id=course_id)} <%block name="bodyclass">view-iframe-content view-partial-mktgregister diff --git a/lms/templates/courseware/mktg_course_about.html b/lms/templates/courseware/mktg_course_about.html index 220dd45550..64c6661a30 100644 --- a/lms/templates/courseware/mktg_course_about.html +++ b/lms/templates/courseware/mktg_course_about.html @@ -8,7 +8,7 @@ <%inherit file="../mktg_iframe.html" /> -<%block name="title">${_("About {course_number}").format(course_number=course.display_number_with_default) | h} +<%block name="pagetitle">${_("About {course_number}").format(course_number=course.display_number_with_default) | h} <%block name="bodyclass">view-iframe-content view-partial-mktgregister diff --git a/lms/templates/courseware/news.html b/lms/templates/courseware/news.html index c56319b4f7..fa848c0036 100644 --- a/lms/templates/courseware/news.html +++ b/lms/templates/courseware/news.html @@ -2,7 +2,7 @@ <%inherit file="main.html" /> <%namespace name='static' file='../static_content.html'/> <%block name="bodyclass">courseware news -<%block name="title">${_("News - MITx 6.002x")} +<%block name="pagetitle">${_("News - MITx 6.002x")} <%block name="headextra"> <%static:css group='style-course-vendor'/> diff --git a/lms/templates/courseware/progress.html b/lms/templates/courseware/progress.html index 6cb80e43e2..59eedd21c4 100644 --- a/lms/templates/courseware/progress.html +++ b/lms/templates/courseware/progress.html @@ -9,7 +9,7 @@ <%namespace name="progress_graph" file="/courseware/progress_graph.js"/> -<%block name="title">${_("{course_number} Progress").format(course_number=course.display_number_with_default) | h} +<%block name="pagetitle">${_("{course_number} Progress").format(course_number=course.display_number_with_default) | h} <%! from django.core.urlresolvers import reverse @@ -26,6 +26,7 @@ ${progress_graph.body(grade_summary, course.grade_cutoffs, "grade-detail-graph", +<%include file="/dashboard/_dashboard_prompt_midcourse_reverify.html" /> <%include file="/courseware/course_navigation.html" args="active_page='progress'" />
diff --git a/lms/templates/courseware/static_tab.html b/lms/templates/courseware/static_tab.html index 464aec0bca..2efc351cc4 100644 --- a/lms/templates/courseware/static_tab.html +++ b/lms/templates/courseware/static_tab.html @@ -7,7 +7,7 @@ <%static:css group='style-course'/> -<%block name="title">${course.display_number_with_default | h} ${tab['name']} +<%block name="pagetitle">${tab['name']} | ${course.display_number_with_default | h} <%include file="/courseware/course_navigation.html" args="active_page='static_tab_{0}'.format(tab['url_slug'])" /> diff --git a/lms/templates/courseware/syllabus.html b/lms/templates/courseware/syllabus.html index d26fd259f7..302e14a0bb 100644 --- a/lms/templates/courseware/syllabus.html +++ b/lms/templates/courseware/syllabus.html @@ -7,7 +7,7 @@ <%static:css group='style-course'/> -<%block name="title">${_("{course.display_number_with_default} Course Info").format(course=course) | h} +<%block name="pagetitle">${_("{course.display_number_with_default} Course Info").format(course=course) | h} <%include file="/courseware/course_navigation.html" args="active_page='syllabus'" /> <%! diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html index 523eecfe96..a2104b5d7a 100644 --- a/lms/templates/dashboard.html +++ b/lms/templates/dashboard.html @@ -8,7 +8,7 @@ <%namespace name='static' file='static_content.html'/> -<%block name="title">${_("Dashboard")} +<%block name="pagetitle">${_("Dashboard")} <%block name="bodyclass">view-dashboard is-authenticated <%block name="js_extra"> @@ -23,6 +23,15 @@ $(this).closest('.message.is-expandable').toggleClass('is-expanded'); } + $("#failed-verification-button-dismiss").click(function(event) { + $.ajax({ + url: "${reverse('verify_student_toggle_failed_banner_off')}", + type: "post", + data: { 'user_id': ${user.id}, } + }) + $("#failed-verification-banner").addClass('is-hidden'); + }) + $("#upgrade-to-verified").click(function(event) { user = $(event.target).data("user"); course = $(event.target).data("course-id"); @@ -152,6 +161,12 @@ +% if reverifications["must_reverify"] or reverifications["denied"]: +
+ <%include file='dashboard/_dashboard_prompt_midcourse_reverify.html' /> +
+% endif +
%if message: @@ -189,6 +204,8 @@ <%include file='dashboard/_dashboard_status_verification.html' /> + <%include file='dashboard/_dashboard_reverification_sidebar.html' /> +
diff --git a/lms/templates/dashboard/_dashboard_prompt_midcourse_reverify.html b/lms/templates/dashboard/_dashboard_prompt_midcourse_reverify.html new file mode 100644 index 0000000000..8e200db401 --- /dev/null +++ b/lms/templates/dashboard/_dashboard_prompt_midcourse_reverify.html @@ -0,0 +1,82 @@ +<%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> + +% if reverifications["must_reverify"]: + % if len(reverifications["must_reverify"]) > 1: + +
+
+
+

${_("You need to re-verify to continue")}

+
+

+ ${_("To continue in the ID Verified track in the following courses, you need to re-verify your identity:")} +

+
    + % for item in reverifications["must_reverify"]: +
  • + ${_('{course_name}: Re-verify by {date}').format(course_name="item.course_name", date=item.date)} +
  • + % endfor +
+
+
+ +
+
+
+ + % elif reverifications["must_reverify"]: +
+
+
+

${_("You need to re-verify to continue")}

+ % for item in reverifications["must_reverify"]: +
+

+ ${_('To continue in the ID Verified track in {course_name}, you need to re-verify your identity by {date}.').format(course_name="" + item.course_name + "", date=item.date)} +

+
+
+ +
+
+
+ % endfor + %endif +%endif + +%if reverifications["denied"] and denied_banner: +
+
+
+

${_("Your re-verification failed")}

+ % for item in reverifications["denied"]: + % if item.display: +
+

+ ${_('Your re-verification for {course_name} failed and you are no longer eligible for a Verified Certificate. If you think this is in error, please contact us at {email}.').format(course_name="" + item.course_name+ "", email='{email}'.format( + email=billing_email + ))} +

+
+
+
+ +
+
+
+ +% endif +% endfor +%endif diff --git a/lms/templates/dashboard/_dashboard_reverification_sidebar.html b/lms/templates/dashboard/_dashboard_reverification_sidebar.html new file mode 100644 index 0000000000..7d9ca6c820 --- /dev/null +++ b/lms/templates/dashboard/_dashboard_reverification_sidebar.html @@ -0,0 +1,37 @@ +<%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> + + + +% if reverifications["must_reverify"] or reverifications["pending"] or reverifications["denied"] or reverifications["approved"]: +
  • + ${_("Re-verification now open for:")} + +
      + + % if reverifications["must_reverify"]: + % for item in reverifications["must_reverify"]: +
    • ${_('Re-verify now:')} ${item.course_name}
    • + % endfor + %endif + + % if reverifications["pending"]: + % for item in reverifications["pending"]: +
    • ${_('Pending:')} ${item.course_name}
    • + % endfor + %endif + + % if reverifications["denied"]: + % for item in reverifications["denied"]: +
    • ${_('Denied:')} ${item.course_name}
    • + % endfor + %endif + + % if reverifications["approved"]: + % for item in reverifications["approved"]: +
    • ${_('Approved:')} ${item.course_name}
    • + % endfor + %endif +
    +
  • +%endif diff --git a/lms/templates/discussion/_underscore_templates.html b/lms/templates/discussion/_underscore_templates.html index 5a5542a98e..2344d006ad 100644 --- a/lms/templates/discussion/_underscore_templates.html +++ b/lms/templates/discussion/_underscore_templates.html @@ -5,10 +5,15 @@ diff --git a/lms/templates/discussion/index.html b/lms/templates/discussion/index.html index 04e367c8b9..4d4c350fe0 100644 --- a/lms/templates/discussion/index.html +++ b/lms/templates/discussion/index.html @@ -6,7 +6,7 @@ <%inherit file="../main.html" /> <%namespace name='static' file='../static_content.html'/> <%block name="bodyclass">discussion -<%block name="title">${_("Discussion - {course_number}").format(course_number=course.display_number_with_default) | h} +<%block name="pagetitle">${_("Discussion - {course_number}").format(course_number=course.display_number_with_default) | h} <%block name="headextra"> <%static:css group='style-course-vendor'/> diff --git a/lms/templates/discussion/mustache/_inline_thread.mustache b/lms/templates/discussion/mustache/_inline_thread.mustache index 1b702126cf..863a2f5a1b 100644 --- a/lms/templates/discussion/mustache/_inline_thread.mustache +++ b/lms/templates/discussion/mustache/_inline_thread.mustache @@ -2,7 +2,12 @@ \ No newline at end of file + diff --git a/lms/templates/discussion/mustache/_inline_thread_cohorted.mustache b/lms/templates/discussion/mustache/_inline_thread_cohorted.mustache index 81803dcd16..b416755e50 100644 --- a/lms/templates/discussion/mustache/_inline_thread_cohorted.mustache +++ b/lms/templates/discussion/mustache/_inline_thread_cohorted.mustache @@ -4,9 +4,14 @@
    {{group_string}}
    - +
    + +
      -
    1. ${_("Loading content")})
    2. +
    3. ${_("Loading content")}

    ${_("Post a response:")}

    @@ -22,4 +27,4 @@ ${_("Hide discussion")} - \ No newline at end of file + diff --git a/lms/templates/discussion/user_profile.html b/lms/templates/discussion/user_profile.html index 53ff77a78b..7f5cee42d6 100644 --- a/lms/templates/discussion/user_profile.html +++ b/lms/templates/discussion/user_profile.html @@ -4,7 +4,7 @@ <%inherit file="../main.html" /> <%namespace name='static' file='../static_content.html'/> <%block name="bodyclass">discussion -<%block name="title">${_("Discussion - {course_number}").format(course_number=course.display_number_with_default) | h} +<%block name="pagetitle">${_("Discussion - {course_number}").format(course_number=course.display_number_with_default) | h} <%block name="headextra"> <%static:css group='style-course-vendor'/> diff --git a/lms/templates/extauth_failure.html b/lms/templates/extauth_failure.html index adc680a7de..9c92e39433 100644 --- a/lms/templates/extauth_failure.html +++ b/lms/templates/extauth_failure.html @@ -2,7 +2,7 @@ - + ${_("External Authentication failed")} diff --git a/lms/templates/index.html b/lms/templates/index.html index 205734d6cc..d8e6d75278 100644 --- a/lms/templates/index.html +++ b/lms/templates/index.html @@ -12,7 +12,7 @@ show_homepage_promo_video = MicrositeConfiguration.get_microsite_configuration_value('show_homepage_promo_video', True) homepage_promo_video_youtube_id = MicrositeConfiguration.get_microsite_configuration_value('homepage_promo_video_youtube_id', "XNaiOGxWeto") - + show_partners = MicrositeConfiguration.get_microsite_configuration_value('show_partners', True) %> @@ -70,116 +70,6 @@
    - ## Disable university partner logos and sites for non-edX sites - % if not self.theme_enabled() and show_partners: -

    ${_('Explore free courses from {span_start}{platform_name}{span_end} universities').format(platform_name="edX", span_start='', span_end='')}

    - -
    -
      -
    1. - - -
      - MITx -
      -
      -
    2. -
    3. - - -
      - HarvardX -
      -
      -
    4. -
    5. - - -
      - BerkeleyX -
      -
      -
    6. -
    7. - - -
      - UTx -
      -
      -
    8. -
    9. - - -
      - McGillX -
      -
      -
    10. -
    11. - - -
      - ANUx -
      -
      -
    12. -
    - -
    - -
      -
    1. - - -
      - WellesleyX -
      -
      -
    2. -
    3. - - -
      - GeorgetownX -
      -
      -
    4. -
    5. - - -
      - University of TorontoX -
      -
      -
    6. -
    7. - - -
      - EPFLx -
      -
      -
    8. -
    9. - - -
      - DelftX -
      -
      -
    10. -
    11. - - -
      - RiceX -
      -
      -
    12. -
    -
    - % endif % if settings.FEATURES.get('COURSES_ARE_BROWSABLE'):
    @@ -192,6 +82,7 @@
    % endif +
    diff --git a/lms/templates/instructor/instructor_dashboard_2/instructor_dashboard_2.html b/lms/templates/instructor/instructor_dashboard_2/instructor_dashboard_2.html index b628a5af84..d9dcdde13b 100644 --- a/lms/templates/instructor/instructor_dashboard_2/instructor_dashboard_2.html +++ b/lms/templates/instructor/instructor_dashboard_2/instructor_dashboard_2.html @@ -1,6 +1,7 @@ <%! from django.utils.translation import ugettext as _ %> -<%inherit file="/main.html" /> <%! from django.core.urlresolvers import reverse %> + +<%inherit file="/main.html" /> <%namespace name='static' file='/static_content.html'/> ## ----- Tips on adding something to the new instructor dashboard ----- @@ -16,6 +17,8 @@ ## 5. Implement your standard django/python in lms/djangoapps/instructor/views/api.py ## 6. And tests go in lms/djangoapps/instructor/tests/ +<%block name="pagetitle">${_("Instructor Dashboard")} + <%block name="headextra"> <%static:css group='style-course-vendor'/> <%static:css group='style-course'/> diff --git a/lms/templates/instructor/instructor_dashboard_2/membership.html b/lms/templates/instructor/instructor_dashboard_2/membership.html index aa05b47e17..3be2a7926f 100644 --- a/lms/templates/instructor/instructor_dashboard_2/membership.html +++ b/lms/templates/instructor/instructor_dashboard_2/membership.html @@ -5,9 +5,7 @@
    {{title}}
    -
    -
    {{info}}
    @@ -20,6 +18,7 @@
    +
    {{info}}
    @@ -117,8 +116,9 @@ data-rolename="Administrator" data-display-name="${_("Forum Admins")}" data-info-text=" - ${_("Forum admins can moderate the course forums as well as administer " - "other forum roles.")}" + ${_("Forum admins can edit or delete any post, clear misuse flags, close " + "and re-open threads, endorse responses, and see posts from all cohorts. " + "They CAN add/delete other moderators and their posts are marked as 'staff'.")}" data-list-endpoint="${ section_data['list_forum_members_url'] }" data-modify-endpoint="${ section_data['update_forum_role_membership_url'] }" data-add-button-label="Add ${_("Forum Admin")}" @@ -130,8 +130,9 @@ data-rolename="Moderator" data-display-name="${_("Forum Moderators")}" data-info-text=" - ${_("Forum moderators can moderate the course forums. They cannot add other " - "moderators.")}" + ${_("Forum moderators can edit or delete any post, clear misuse flags, close " + "and re-open threads, endorse responses, and see posts from all cohorts. " + "They CANNOT add/delete other moderators and their posts are marked as 'staff'.")}" data-list-endpoint="${ section_data['list_forum_members_url'] }" data-modify-endpoint="${ section_data['update_forum_role_membership_url'] }" data-add-button-label="${_("Add Moderator")}" @@ -142,7 +143,9 @@ data-display-name="${_("Forum Community TAs")}" data-info-text=" ${_("Community TA's are members of the community whom you deem particularly " - "helpful on the forums.")}" + "helpful on the forums. They can edit or delete any post, clear misuse flags, " + "close and re-open threads, endorse responses, and see posts from all cohorts. " + "Their posts are marked 'Community TA'.")}" data-list-endpoint="${ section_data['list_forum_members_url'] }" data-modify-endpoint="${ section_data['update_forum_role_membership_url'] }" data-add-button-label="Add ${_("Community TA")}" diff --git a/lms/templates/instructor/staff_grading.html b/lms/templates/instructor/staff_grading.html index 09b41f91aa..dfec9af5f8 100644 --- a/lms/templates/instructor/staff_grading.html +++ b/lms/templates/instructor/staff_grading.html @@ -9,7 +9,7 @@ -<%block name="title">${_("{course_number} Staff Grading").format(course_number=course.display_number_with_default) | h} +<%block name="pagetitle">${_("{course_number} Staff Grading").format(course_number=course.display_number_with_default) | h} <%include file="/courseware/course_navigation.html" args="active_page='staff_grading'" /> diff --git a/lms/templates/login.html b/lms/templates/login.html index e8ddcd1b20..ed3dbf457e 100644 --- a/lms/templates/login.html +++ b/lms/templates/login.html @@ -5,7 +5,7 @@ <%! from django.core.urlresolvers import reverse %> <%! from django.utils.translation import ugettext as _ %> -<%block name="title">${_("Log into your {platform_name} Account").format(platform_name=platform_name)} +<%block name="pagetitle">${_("Log into your {platform_name} Account").format(platform_name=platform_name)} <%block name="js_extra"> - % endif + + ${page_title_breadcrumbs(self.pagetitle())} + + + @@ -102,7 +101,7 @@ - + ${_("Skip to this view's content")} <%include file="mathjax_accessible.html" /> diff --git a/lms/templates/main_django.html b/lms/templates/main_django.html index 9190ddab92..da82d81720 100644 --- a/lms/templates/main_django.html +++ b/lms/templates/main_django.html @@ -1,9 +1,8 @@ -{% load compressed %}{% load sekizai_tags i18n %}{% load url from future %}{% load staticfiles %} - +{% load compressed %}{% load sekizai_tags i18n microsite %}{% load url from future %}{% load staticfiles %} + - {# "edX" should *not* be translated #} - {% block title %}edX{% endblock %} + {% block title %}{% platform_name %}{% endblock %} @@ -28,7 +27,7 @@ - + {% trans "Skip to this view's content" %} {% include "navigation.html" %}
    diff --git a/lms/templates/mktg_iframe.html b/lms/templates/mktg_iframe.html index ea1b00f1ad..e6c0f55acd 100644 --- a/lms/templates/mktg_iframe.html +++ b/lms/templates/mktg_iframe.html @@ -1,9 +1,9 @@ <%namespace name='static' file='static_content.html'/> - - - - + + + + <%block name="title"> diff --git a/lms/templates/open_ended_problems/combined_notifications.html b/lms/templates/open_ended_problems/combined_notifications.html index 47bb01fc8e..24fcb5351f 100644 --- a/lms/templates/open_ended_problems/combined_notifications.html +++ b/lms/templates/open_ended_problems/combined_notifications.html @@ -8,7 +8,7 @@ <%static:css group='style-course'/> -<%block name="title">${_("{course_number} Combined Notifications").format(course_number=course.display_number_with_default) | h} +<%block name="pagetitle">${_("{course_number} Combined Notifications").format(course_number=course.display_number_with_default)} <%include file="/courseware/course_navigation.html" args="active_page='open_ended'" /> diff --git a/lms/templates/open_ended_problems/open_ended_flagged_problems.html b/lms/templates/open_ended_problems/open_ended_flagged_problems.html index 87d5a11bd8..61053c663b 100644 --- a/lms/templates/open_ended_problems/open_ended_flagged_problems.html +++ b/lms/templates/open_ended_problems/open_ended_flagged_problems.html @@ -8,7 +8,7 @@ <%static:css group='style-course'/> -<%block name="title">${_("{course_number} Flagged Open Ended Problems").format(course_number=course.display_number_with_default) | h} +<%block name="pagetitle">${_("{course_number} Flagged Open Ended Problems").format(course_number=course.display_number_with_default)} <%include file="/courseware/course_navigation.html" args="active_page='open_ended_flagged_problems'" /> diff --git a/lms/templates/open_ended_problems/open_ended_problems.html b/lms/templates/open_ended_problems/open_ended_problems.html index d5edc8edaf..33a05d088d 100644 --- a/lms/templates/open_ended_problems/open_ended_problems.html +++ b/lms/templates/open_ended_problems/open_ended_problems.html @@ -8,7 +8,7 @@ <%static:css group='style-course'/> -<%block name="title">${_("{course_number} Open Ended Problems").format(course_number=course.display_number_with_default) | h} +<%block name="pagetitle">${_("{course_number} Open Ended Problems").format(course_number=course.display_number_with_default)} <%include file="/courseware/course_navigation.html" args="active_page='open_ended_problems'" /> diff --git a/lms/templates/register-shib.html b/lms/templates/register-shib.html index 5b5a64c1af..3ef75251ac 100644 --- a/lms/templates/register-shib.html +++ b/lms/templates/register-shib.html @@ -12,7 +12,7 @@ <%! from datetime import date %> <%! import calendar %> -<%block name="title">${_("Preferences for {platform_name}").format(platform_name=settings.PLATFORM_NAME)} +<%block name="pagetitle">${_("Preferences for {platform_name}").format(platform_name=settings.PLATFORM_NAME)} <%block name="js_extra"> + + + + + +<%block name="content"> + + + + + +%if error: +
    +
    + +
    +

    ${_("Error submitting your images")}

    +
    +

    ${_("Oops! Something went wrong. Please confirm your details and try again.")}

    +
    +
    +
    +
    +%endif + +
    +
    + +
    +
    + +
    + + <%include file="_verification_header.html" args="course_name=course_name" /> + +
    +
    +

    ${_("Re-Take Your Photo")}

    +
    +

    ${_("Use your webcam to take a picture of your face so we can match it with your original verification.")}

    +
    + +
    + +
    +
    + +
    +

    ${_("Don't see your picture? Make sure to allow your browser to use your camera when it asks for permission.")}

    +
    + +
    + +
    + + +
    + +
    +
    +

    ${_("Tips on taking a successful photo")}

    + +
    +
      +
    • ${_("Make sure your face is well-lit")}
    • +
    • ${_("Be sure your entire face is inside the frame")}
    • +
    • ${_("Can we match the photo you took with the one on your ID?")}
    • +
    • ${_("Once in position, use the camera button")} () ${_("to capture your picture")}
    • +
    • ${_("Use the checkmark button")} () ${_("once you are happy with the photo")}
    • +
    +
    +
    + +
    +

    ${_("Common Questions")}

    + +
    +
    +
    ${_("Why do you need my photo?")}
    +
    ${_("As part of the verification process, we need your photo to confirm that you are you.")}
    + +
    ${_("What do you do with this picture?")}
    +
    ${_("We only use it to verify your identity. It is not displayed anywhere.")}
    +
    +
    +
    +
    +
    + + +
    + +

    ${_("Check Your Name")}

    + +
    +

    ${_("Make sure your full name on your edX account ({full_name}) matches the ID you originally submitted. We will also use this as the name on your certificate.").format(full_name="" + user_full_name + "")}

    +
    + + +
    + + + + + +
    +
    +
    +
    +
    + + <%include file="_reverification_support.html" /> +
    +
    + +<%include file="_modal_editname.html" /> + diff --git a/lms/templates/verify_student/midcourse_reverification_confirmation.html b/lms/templates/verify_student/midcourse_reverification_confirmation.html new file mode 100644 index 0000000000..7cfadc6283 --- /dev/null +++ b/lms/templates/verify_student/midcourse_reverification_confirmation.html @@ -0,0 +1,47 @@ + +<%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> +<%inherit file="../main.html" /> +<%namespace name='static' file='/static_content.html'/> + +<%block name="bodyclass">register verification-process is-not-verified step-confirmation +<%block name="title">${_("Re-Verification Submission Confirmation")} + +<%block name="content"> + +
    +
    + +
    +
    +
    +
    +
    +

    ${_("Your Credentials Have Been Updated")}

    + +
    +

    ${_("We have received your re-verification details and submitted them for review. Your dashboard will show the notification status once the review is complete.")}

    +

    ${_("Please note: The professor may ask you to re-verify again at other key points in the course.")}

    +
    + + + +
    +
    +
    +
    +
    + + <%include file="_reverification_support.html" /> +
    +
    + diff --git a/lms/templates/verify_student/midcourse_reverify_dash.html b/lms/templates/verify_student/midcourse_reverify_dash.html new file mode 100644 index 0000000000..06cf47ba65 --- /dev/null +++ b/lms/templates/verify_student/midcourse_reverify_dash.html @@ -0,0 +1,147 @@ +<%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> +<%inherit file="../main.html" /> +<%block name="bodyclass">midcourse-reverification-process step-dash register +<%block name="title"> + + ${_("Reverification Status")} + + + +<%block name="content"> +
    +
    +
    +
    + +

    ${_("You are in the ID Verified track")}

    + +
    + + % if reverifications["must_reverify"]: # If you have reverifications to do + % if len(reverifications["must_reverify"]) > 1: # If you have >1 reverifications +
    +

    ${_("You currently need to re-verify for the following courses:")}

    +
      + % for item in reverifications["must_reverify"]: # for 1st +
    • +
      +

      ${item.course_name} (${item.course_number})

      +

      ${_('Re-verify by {date}').format(date="" + item.date + "")}

      +
      +

      Re-verify for ${item.course_number}

      +
    • + % endfor +
    +
    + + % else: # You only have one reverification +
    +

    ${_("You currently need to re-verify for the following course:")}

    + +
      + % for item in reverifications["must_reverify"]: +
    • +
      +

      ${item.course_name} (${item.course_number})

      +

      ${_('Re-verify by {date}').format(date="" + item.date + "")}

      +
      +

      Re-verify for ${item.course_number}

      +
    • + % endfor +
    +
    + %endif + % else: +
    +

    ${_("You have no re-verifications at present.")}

    +
    + %endif + + % if reverifications["pending"] or reverifications["approved"] or reverifications["denied"]: +
    +

    ${_("The status of your submitted re-verifications:")}

    +
      + + % for item in reverifications["pending"]: +
    • +
      +

      ${item.course_name} (${item.course_number})

      +

      ${_('Re-verify by {date}').format(date="" + item.date + "")}

      +
      +

      ${_("Pending")}

      +
    • + % endfor + + % for item in reverifications["approved"]: +
    • +
      +

      ${item.course_name} (${item.course_number})

      +

      ${_('Re-verify by {date}').format(date="" + item.date + "")}

      +
      +

      ${_("Complete")}

      +
    • + % endfor + + % for item in reverifications["denied"]: +
    • +
      +

      ${item.course_name} (${item.course_number})

      +

      ${_('Re-verify by {date}').format(date="" + item.date + "")}

      +
      +

      ${_("Failed")}

      +
    • + % endfor + +
    +
    + % endif + + % if reverifications["must_reverify"]: +

    ${_("Don't want to re-verify right now? {a_start}Return to where you left off{a_end}").format( + a_start=''.format(url=referer), + a_end="", + )}

    + % else: +

    ${_("{a_start}Return to where you left off{a_end}").format( + a_start=''.format(url=referer), + a_end="", + )}

    + % endif + +
    + +
    + +
    +

    ${_("Why do I need to re-verify?")}

    +
    + +

    ${_("At key points in a course, the professor will ask you to re-verify your identity by submitting a new photo of your face. We will send the new photo to be matched up with the photo of the original ID you submitted when you signed up for the course. If you are taking multiple courses, you may need to re-verify multiple times, once for every important point in each course you are taking as a verified student.")}

    +
    +
    + +
    +

    ${_("What will I need to re-verify?")}

    +
    + +

    ${_("Because you are just confirming that you are still you, the only thing you will need to do to re-verify is to submit a new photo of your face with your webcam. The process is quick and you will be brought back to where you left off so you can keep on learning.")}

    + +

    ${_("If you changed your name during the semester and it no longer matches the original ID you submitted, you will need to re-edit your name to match as well.")}

    +
    +
    + +
    +

    ${_("What if I have trouble with my re-verification?")}

    +
    +

    ${_('Because of the short time that re-verification is open, you will not be able to correct a failed verification. If you think there was an error in the review, please contact us at {email}').format(email='{email}.'.format(email=billing_email))}

    +
    +
    +
    + +
    +
    + +
    +
    + diff --git a/lms/templates/verify_student/photo_reverification.html b/lms/templates/verify_student/photo_reverification.html index 4203afe773..921650cfbd 100644 --- a/lms/templates/verify_student/photo_reverification.html +++ b/lms/templates/verify_student/photo_reverification.html @@ -1,10 +1,11 @@ <%! from django.utils.translation import ugettext as _ %> <%! from django.core.urlresolvers import reverse %> + <%inherit file="../main.html" /> <%namespace name='static' file='/static_content.html'/> <%block name="bodyclass">register verification-process is-not-verified step-photos -<%block name="title">${_("Re-Verification")} +<%block name="pagetitle">${_("Re-Verification")} <%block name="js_extra"> diff --git a/lms/templates/verify_student/photo_verification.html b/lms/templates/verify_student/photo_verification.html index 8e754452c0..f683ff905f 100644 --- a/lms/templates/verify_student/photo_verification.html +++ b/lms/templates/verify_student/photo_verification.html @@ -1,17 +1,16 @@ <%! from django.utils.translation import ugettext as _ %> <%! from django.core.urlresolvers import reverse %> + <%inherit file="../main.html" /> <%namespace name='static' file='/static_content.html'/> <%block name="bodyclass">register verification-process step-photos ${'is-upgrading' if upgrade else ''} -<%block name="title"> - +<%block name="pagetitle"> %if upgrade: ${_("Upgrade Your Registration for {} | Verification").format(course_name)} %else: ${_("Register for {} | Verification").format(course_name)} %endif - <%block name="js_extra"> diff --git a/lms/templates/verify_student/prompt_midcourse_reverify.html b/lms/templates/verify_student/prompt_midcourse_reverify.html new file mode 100644 index 0000000000..4b5f9f75e7 --- /dev/null +++ b/lms/templates/verify_student/prompt_midcourse_reverify.html @@ -0,0 +1,5 @@ +<%! from django.utils.translation import ugettext as _ %> +

    ${_("You need to re-verify to continue")}

    +

    + ${_("To continue in the ID Verified track in {course}, you need to re-verify your identity by {date}. Go to URL.").format(email)} +

    diff --git a/lms/templates/verify_student/reverification_confirmation.html b/lms/templates/verify_student/reverification_confirmation.html index 5b2ee17333..47751edc5a 100644 --- a/lms/templates/verify_student/reverification_confirmation.html +++ b/lms/templates/verify_student/reverification_confirmation.html @@ -1,11 +1,11 @@ - <%! from django.utils.translation import ugettext as _ %> <%! from django.core.urlresolvers import reverse %> + <%inherit file="../main.html" /> <%namespace name='static' file='/static_content.html'/> <%block name="bodyclass">register verification-process is-not-verified step-confirmation -<%block name="title">${_("Re-Verification Submission Confirmation")} +<%block name="pagetitle">${_("Re-Verification Submission Confirmation")} <%block name="js_extra"> diff --git a/lms/templates/verify_student/reverification_window_expired.html b/lms/templates/verify_student/reverification_window_expired.html new file mode 100644 index 0000000000..b113f8d44f --- /dev/null +++ b/lms/templates/verify_student/reverification_window_expired.html @@ -0,0 +1,45 @@ + +<%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> +<%inherit file="../main.html" /> +<%namespace name='static' file='/static_content.html'/> + +<%block name="bodyclass">register verification-process is-not-verified step-confirmation +<%block name="title">${_("Re-Verification Failed")} + +<%block name="js_extra"> + + + +<%block name="content"> + +
    +
    + +
    +
    +
    +
    +
    +

    ${_("Re-Verification Failed")}

    + +
    +

    ${_("Your re-verification was submitted after the re-verification deadline, and you can no longer be re-verified.")}

    +

    ${_("Please contact support if you believe this message to be in error.")}

    +
    + +
      + +
    +
    +
    +
    +
    +
    + + <%include file="_reverification_support.html" /> +
    +
    + diff --git a/lms/templates/verify_student/show_requirements.html b/lms/templates/verify_student/show_requirements.html index ed9fa4747b..8660db36aa 100644 --- a/lms/templates/verify_student/show_requirements.html +++ b/lms/templates/verify_student/show_requirements.html @@ -1,15 +1,14 @@ <%! from django.utils.translation import ugettext as _ %> <%! from django.core.urlresolvers import reverse %> + <%inherit file="../main.html" /> <%block name="bodyclass">register verification-process step-requirements ${'is-upgrading' if upgrade else ''} -<%block name="title"> - +<%block name="pagetitle"> %if upgrade: ${_("Upgrade Your Registration for {}").format(course_name)} %else: ${_("Register for {}").format(course_name)} %endif - <%block name="content"> diff --git a/lms/templates/verify_student/verified.html b/lms/templates/verify_student/verified.html index 41b4b312b9..965fe498f1 100644 --- a/lms/templates/verify_student/verified.html +++ b/lms/templates/verify_student/verified.html @@ -1,10 +1,11 @@ <%! from django.utils.translation import ugettext as _ %> <%! from django.core.urlresolvers import reverse %> + <%inherit file="../main.html" /> <%namespace name='static' file='/static_content.html'/> <%block name="bodyclass">register verification-process is-verified -<%block name="title">${_("Register for {} | Verification").format(course_name)} +<%block name="pagetitle">${_("Register for {} | Verification").format(course_name)} <%block name="js_extra">