15
.gitignore
vendored
15
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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/<lang>/LC_MESSAGES/djangojs.po
|
||||
source_file = conf/locale/en/LC_MESSAGES/djangojs.po
|
||||
[edx-platform.djangojs-partial]
|
||||
file_filter = conf/locale/<lang>/LC_MESSAGES/djangojs-partial.po
|
||||
source_file = conf/locale/en/LC_MESSAGES/djangojs-partial.po
|
||||
source_lang = en
|
||||
type = PO
|
||||
|
||||
|
||||
2
AUTHORS
2
AUTHORS
@@ -105,4 +105,4 @@ Yihua Lou <supermouselyh@hotmail.com>
|
||||
Andy Armstrong <andya@edx.org>
|
||||
Matt Drayer <mattdrayer@edx.org>
|
||||
Cristian Salamea <cristian.salamea@iaen.edu.ec>
|
||||
|
||||
Graham Lowe <graham.lowe@gmail.com>
|
||||
|
||||
@@ -5,6 +5,8 @@ These are notable changes in edx-platform. This is a rolling list of changes,
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
20
cms/djangoapps/contentstore/context_processors.py
Normal file
20
cms/djangoapps/contentstore/context_processors.py
Normal file
@@ -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}
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = '<tar.gz archive file> <output path>'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"Execute the command"
|
||||
if len(args) != 2:
|
||||
raise CommandError("export requires two arguments: <tar.gz file> <output path>")
|
||||
|
||||
source_archive = args[0]
|
||||
output_path = args[1]
|
||||
|
||||
# Create temp directories to extract the source and create the target archive.
|
||||
temp_source_dir = mkdtemp()
|
||||
temp_target_dir = mkdtemp()
|
||||
try:
|
||||
extract_source(source_archive, temp_source_dir)
|
||||
|
||||
desired_version = convert_between_versions(temp_source_dir, temp_target_dir)
|
||||
|
||||
# New zip up the target directory.
|
||||
parts = os.path.basename(source_archive).split('.')
|
||||
archive_name = path(output_path) / "{source_name}_version_{desired_version}.tar.gz".format(
|
||||
source_name=parts[0], desired_version=desired_version
|
||||
)
|
||||
with open(archive_name, "w"):
|
||||
tar_file = tarfile.open(archive_name, mode='w:gz')
|
||||
try:
|
||||
for item in os.listdir(temp_target_dir):
|
||||
tar_file.add(path(temp_target_dir) / item, arcname=item)
|
||||
|
||||
finally:
|
||||
tar_file.close()
|
||||
|
||||
print("Created archive {0}".format(archive_name))
|
||||
|
||||
except ValueError as err:
|
||||
raise CommandError(err)
|
||||
|
||||
finally:
|
||||
shutil.rmtree(temp_source_dir)
|
||||
shutil.rmtree(temp_target_dir)
|
||||
|
||||
|
||||
def extract_source(source_archive, target):
|
||||
"""
|
||||
Extract the archive into the given target directory.
|
||||
"""
|
||||
with tarfile.open(source_archive) as tar_file:
|
||||
safetar_extractall(tar_file, target)
|
||||
@@ -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 <locator>"
|
||||
|
||||
def parse_args(self, *args):
|
||||
"""
|
||||
Return a three-tuple of (location, user, locator_string).
|
||||
If the user didn't specify a locator string, the third return value
|
||||
will be None.
|
||||
"""
|
||||
if len(args) < 2:
|
||||
raise CommandError(
|
||||
"migrate_to_split requires at least two arguments: "
|
||||
"a location and a user identifier (email or ID)"
|
||||
)
|
||||
|
||||
try:
|
||||
location = Location(args[0])
|
||||
except InvalidLocationError:
|
||||
raise CommandError("Invalid location string {}".format(args[0]))
|
||||
|
||||
try:
|
||||
user = user_from_str(args[1])
|
||||
except User.DoesNotExist:
|
||||
raise CommandError("No user found identified by {}".format(args[1]))
|
||||
|
||||
try:
|
||||
package_id = args[2]
|
||||
except IndexError:
|
||||
package_id = None
|
||||
|
||||
return location, user, package_id
|
||||
|
||||
def handle(self, *args, **options):
|
||||
location, user, package_id = self.parse_args(*args)
|
||||
|
||||
migrator = SplitMigrator(
|
||||
draft_modulestore=modulestore('default'),
|
||||
direct_modulestore=modulestore('direct'),
|
||||
split_modulestore=modulestore('split'),
|
||||
loc_mapper=loc_mapper(),
|
||||
)
|
||||
|
||||
migrator.migrate_mongo_course(location, user, package_id)
|
||||
@@ -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.'
|
||||
)
|
||||
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
|
||||
42
cms/djangoapps/contentstore/tests/test_access.py
Normal file
42
cms/djangoapps/contentstore/tests/test_access.py
Normal file
@@ -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)
|
||||
)
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 '';
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
<%namespace name='static' file='static_content.html'/>
|
||||
|
||||
<!doctype html>
|
||||
<!--[if IE 7]><html class="ie7 lte9 lte8 lte7"><![endif]-->
|
||||
<!--[if IE 8]><html class="ie8 lte9 lte8"><![endif]-->
|
||||
<!--[if IE 9]><html class="ie9 lte9"><![endif]-->
|
||||
<!--[if gt IE 9]><!--><html><!--<![endif]-->
|
||||
<!--[if IE 7]><html class="ie7 lte9 lte8 lte7" lang="${LANGUAGE_CODE}"><![endif]-->
|
||||
<!--[if IE 8]><html class="ie8 lte9 lte8" lang="${LANGUAGE_CODE}"><![endif]-->
|
||||
<!--[if IE 9]><html class="ie9 lte9" lang="${LANGUAGE_CODE}"><![endif]-->
|
||||
<!--[if gt IE 9]><!--><html lang="${LANGUAGE_CODE}"><!--<![endif]-->
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
|
||||
@@ -32,7 +32,7 @@
|
||||
<%block name="header_extras"></%block>
|
||||
</head>
|
||||
|
||||
<body class="<%block name='bodyclass'></%block> hide-wip">
|
||||
<body class="<%block name='bodyclass'></%block> hide-wip lang_${LANGUAGE_CODE}">
|
||||
<a class="nav-skip" href="#content">${_("Skip to this view's content")}</a>
|
||||
|
||||
<script type="text/javascript">
|
||||
|
||||
@@ -2,53 +2,54 @@
|
||||
<%namespace name='static' file='static_content.html'/>
|
||||
|
||||
<div class="wrapper wrapper-component-editor">
|
||||
<div class="component-editor">
|
||||
<div class="component-edit-header">
|
||||
<span class="component-name"></span>
|
||||
<ul class="nav-edit-modes">
|
||||
<li id="editor-mode" class="mode active-mode" aria-controls="editor-tab" role="tab">
|
||||
<a href="#">${_("Editor")}</a>
|
||||
</li>
|
||||
<li id="settings-mode" class="mode active-mode" aria-controls="settings-tab" role="tab">
|
||||
<a href="#">${_("Settings")}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div> <!-- Editor Header -->
|
||||
<div class="component-editor">
|
||||
<div class="component-edit-header">
|
||||
<span class="component-name"></span>
|
||||
<ul class="nav-edit-modes">
|
||||
<li id="editor-mode" class="mode active-mode" aria-controls="editor-tab" role="tab">
|
||||
<a href="#">${_("Editor")}</a>
|
||||
</li>
|
||||
<li id="settings-mode" class="mode active-mode" aria-controls="settings-tab" role="tab">
|
||||
<a href="#">${_("Settings")}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div> <!-- Editor Header -->
|
||||
|
||||
<div class="component-edit-modes">
|
||||
<div class="module-editor">
|
||||
${editor}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row module-actions">
|
||||
<a href="#" class="save-button action-primary action">${_("Save")}</a>
|
||||
<a href="#" class="cancel-button action-secondary action">${_("Cancel")}</a>
|
||||
</div> <!-- Module Actions-->
|
||||
<div class="component-edit-modes">
|
||||
<div class="module-editor">
|
||||
${editor}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row module-actions">
|
||||
<a href="#" class="save-button action-primary action">${_("Save")}</a>
|
||||
<a href="#" class="cancel-button action-secondary action">${_("Cancel")}</a>
|
||||
</div> <!-- Module Actions-->
|
||||
</div>
|
||||
</div>
|
||||
<div class="wrapper wrapper-component-action-header">
|
||||
<div class="component-header">
|
||||
</div>
|
||||
<ul class="component-actions">
|
||||
<li class="action-item action-edit">
|
||||
<a href="#" class="edit-button action-button">
|
||||
<i class="icon-edit"></i>
|
||||
<span class="action-button-text">${_("Edit")}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="action-item action-duplicate">
|
||||
<a href="#" data-tooltip="Duplicate" class="duplicate-button action-button">
|
||||
<i class="icon-copy"></i>
|
||||
<span class="sr">${_("Duplicate this component")}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="action-item action-delete">
|
||||
<a href="#" data-tooltip="Delete" class="delete-button action-button">
|
||||
<i class="icon-trash"></i>
|
||||
<span class="sr">${_("Delete this component")}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="component-header">
|
||||
${label}
|
||||
</div>
|
||||
<ul class="component-actions">
|
||||
<li class="action-item action-edit">
|
||||
<a href="#" class="edit-button action-button">
|
||||
<i class="icon-edit"></i>
|
||||
<span class="action-button-text">${_("Edit")}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="action-item action-duplicate">
|
||||
<a href="#" data-tooltip="${_("Duplicate")}" class="duplicate-button action-button">
|
||||
<i class="icon-copy"></i>
|
||||
<span class="sr">${_("Duplicate this component")}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="action-item action-delete">
|
||||
<a href="#" data-tooltip="${_("Delete")}" class="delete-button action-button">
|
||||
<i class="icon-trash"></i>
|
||||
<span class="sr">${_("Delete this component")}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<span data-tooltip="${_("Drag to reorder")}" class="drag-handle"></span>
|
||||
${preview}
|
||||
|
||||
@@ -47,6 +47,7 @@ require(["js/models/explicit_url", "coffee/src/views/tabs"], function(TabsModel,
|
||||
<section class="content">
|
||||
<div class="introduction has-links">
|
||||
<p class="copy">${_("Use Static Pages to share a syllabus, a calendar, handouts, or other supplements to your courseware.")}</p>
|
||||
<p class="copy">${_("NOTE: all content on Static Pages will be visible to anyone who knows the URL, regardless of whether they are registered in the course or not.")}</p>
|
||||
<nav class="nav-introduction-supplementary">
|
||||
<ul>
|
||||
<li class="nav-item">
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<p class="introduction">${_("Ready to start creating online courses? Sign up below and start creating your first edX course today.")}</p>
|
||||
|
||||
<article class="content-primary" role="main">
|
||||
<form id="register_form" method="post" action="register_post">
|
||||
<form id="register_form" method="post">
|
||||
<div id="register_error" name="register_error" class="message message-status message-status error">
|
||||
</div>
|
||||
|
||||
@@ -107,31 +107,25 @@ require(["jquery", "jquery.cookie"], function($) {
|
||||
$("label").removeClass("is-focused");
|
||||
});
|
||||
|
||||
// form validation
|
||||
function postJSON(url, data, callback) {
|
||||
$.ajax({type:'POST',
|
||||
url: url,
|
||||
dataType: 'json',
|
||||
data: data,
|
||||
success: callback,
|
||||
headers : {'X-CSRFToken': $.cookie('csrftoken')}
|
||||
});
|
||||
}
|
||||
|
||||
$('form#register_form').submit(function(e) {
|
||||
e.preventDefault();
|
||||
var submit_data = $('#register_form').serialize();
|
||||
|
||||
postJSON('/create_account',
|
||||
submit_data,
|
||||
function(json) {
|
||||
if(json.success) {
|
||||
location.href = "${'/course'}";
|
||||
} else {
|
||||
$('#register_error').html(json.value).stop().addClass('is-shown');
|
||||
}
|
||||
}
|
||||
);
|
||||
$.ajax({
|
||||
url: '/create_account',
|
||||
type: 'POST',
|
||||
dataType: 'json',
|
||||
data: submit_data,
|
||||
headers: {'X-CSRFToken': $.cookie('csrftoken')},
|
||||
success: function(json) {
|
||||
location.href = "/course";
|
||||
},
|
||||
error: function(jqXHR, textStatus, errorThrown) {
|
||||
json = $.parseJSON(jqXHR.responseText);
|
||||
$('#register_error').html(json.value).stop().addClass('is-shown');
|
||||
},
|
||||
notifyOnError: false
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -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='<a href="{url}">'.format(url=subsection_url),
|
||||
link_start=u'<a href="{url}">'.format(url=subsection_url),
|
||||
link_end='</a>',
|
||||
)}
|
||||
</p>
|
||||
|
||||
@@ -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<key>[^/]*)$', 'student.views.activate_account', name='activate'),
|
||||
|
||||
# ajax view that actually does the work
|
||||
|
||||
@@ -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"))
|
||||
|
||||
0
common/djangoapps/config_models/README.rst
Normal file
0
common/djangoapps/config_models/README.rst
Normal file
62
common/djangoapps/config_models/__init__.py
Normal file
62
common/djangoapps/config_models/__init__.py
Normal file
@@ -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.
|
||||
"""
|
||||
80
common/djangoapps/config_models/admin.py
Normal file
80
common/djangoapps/config_models/admin.py
Normal file
@@ -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,),
|
||||
)
|
||||
)
|
||||
62
common/djangoapps/config_models/models.py
Normal file
62
common/djangoapps/config_models/models.py
Normal file
@@ -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
|
||||
29
common/djangoapps/config_models/templatetags.py
Normal file
29
common/djangoapps/config_models/templatetags.py
Normal file
@@ -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
|
||||
76
common/djangoapps/config_models/tests.py
Normal file
76
common/djangoapps/config_models/tests.py
Normal file
@@ -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)
|
||||
@@ -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
|
||||
|
||||
19
common/djangoapps/dark_lang/__init__.py
Normal file
19
common/djangoapps/dark_lang/__init__.py
Normal file
@@ -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.
|
||||
"""
|
||||
10
common/djangoapps/dark_lang/admin.py
Normal file
10
common/djangoapps/dark_lang/admin.py
Normal file
@@ -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)
|
||||
92
common/djangoapps/dark_lang/middleware.py
Normal file
92
common/djangoapps/dark_lang/middleware.py
Normal file
@@ -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
|
||||
74
common/djangoapps/dark_lang/migrations/0001_initial.py
Normal file
74
common/djangoapps/dark_lang/migrations/0001_initial.py
Normal file
@@ -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']
|
||||
@@ -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
|
||||
0
common/djangoapps/dark_lang/migrations/__init__.py
Normal file
0
common/djangoapps/dark_lang/migrations/__init__.py
Normal file
26
common/djangoapps/dark_lang/models.py
Normal file
26
common/djangoapps/dark_lang/models.py
Normal file
@@ -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
|
||||
210
common/djangoapps/dark_lang/tests.py
Normal file
210
common/djangoapps/dark_lang/tests.py
Normal file
@@ -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')
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
51
common/djangoapps/edxmako/paths.py
Normal file
51
common/djangoapps/edxmako/paths.py
Normal file
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -202,7 +202,7 @@ class ShibSPTest(ModuleStoreTestCase):
|
||||
else:
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response,
|
||||
("<title>Preferences for {platform_name}</title>"
|
||||
("Preferences for {platform_name}"
|
||||
.format(platform_name=settings.PLATFORM_NAME)))
|
||||
# no audit logging calls
|
||||
self.assertEquals(len(audit_log_calls), 0)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from .templatetags.microsite import page_title_breadcrumbs
|
||||
|
||||
@@ -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)
|
||||
32
common/djangoapps/microsite_configuration/test_microsites.py
Normal file
32
common/djangoapps/microsite_configuration/test_microsites.py
Normal file
@@ -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)
|
||||
|
||||
0
common/djangoapps/reverification/__init__.py
Normal file
0
common/djangoapps/reverification/__init__.py
Normal file
8
common/djangoapps/reverification/admin.py
Normal file
8
common/djangoapps/reverification/admin.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""
|
||||
Reverification admin
|
||||
"""
|
||||
|
||||
from ratelimitbackend import admin
|
||||
from reverification.models import MidcourseReverificationWindow
|
||||
|
||||
admin.site.register(MidcourseReverificationWindow)
|
||||
36
common/djangoapps/reverification/migrations/0001_initial.py
Normal file
36
common/djangoapps/reverification/migrations/0001_initial.py
Normal file
@@ -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']
|
||||
54
common/djangoapps/reverification/models.py
Normal file
54
common/djangoapps/reverification/models.py
Normal file
@@ -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
|
||||
0
common/djangoapps/reverification/tests/__init__.py
Normal file
0
common/djangoapps/reverification/tests/__init__.py
Normal file
19
common/djangoapps/reverification/tests/factories.py
Normal file
19
common/djangoapps/reverification/tests/factories.py
Normal file
@@ -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)
|
||||
73
common/djangoapps/reverification/tests/test_models.py
Normal file
73
common/djangoapps/reverification/tests/test_models.py
Normal file
@@ -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()
|
||||
@@ -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<quote>\\?['"]) # the opening quotes
|
||||
(?P<prefix>{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
|
||||
)),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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']
|
||||
complete_apps = ['student']
|
||||
|
||||
@@ -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'}),
|
||||
|
||||
@@ -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']
|
||||
@@ -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']
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
1
common/djangoapps/student/tests/email/test.txt
Normal file
1
common/djangoapps/student/tests/email/test.txt
Normal file
@@ -0,0 +1 @@
|
||||
Test body.
|
||||
1
common/djangoapps/student/tests/email/test_subject.txt
Normal file
1
common/djangoapps/student/tests/email/test_subject.txt
Normal file
@@ -0,0 +1 @@
|
||||
Test subject.
|
||||
1
common/djangoapps/student/tests/emails/test_body.txt
Normal file
1
common/djangoapps/student/tests/emails/test_body.txt
Normal file
@@ -0,0 +1 @@
|
||||
Test body.
|
||||
1
common/djangoapps/student/tests/emails/test_subject.txt
Normal file
1
common/djangoapps/student/tests/emails/test_subject.txt
Normal file
@@ -0,0 +1 @@
|
||||
Test subject.
|
||||
@@ -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, ("<title>Log into your {platform_name} Account</title>"
|
||||
self.assertContains(noshib_response, ("Log into your {platform_name} Account | {platform_name}"
|
||||
.format(platform_name=settings.PLATFORM_NAME)))
|
||||
self.assertEqual(noshib_response.status_code, 200)
|
||||
|
||||
|
||||
50
common/djangoapps/student/tests/test_massemail.py
Normal file
50
common/djangoapps/student/tests/test_massemail.py
Normal file
@@ -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')
|
||||
2
common/djangoapps/student/tests/test_massemail_users.txt
Normal file
2
common/djangoapps/student/tests/test_massemail_users.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
Fred
|
||||
Barney
|
||||
239
common/djangoapps/student/tests/test_password_policy.py
Normal file
239
common/djangoapps/student/tests/test_password_policy.py
Normal file
@@ -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'])
|
||||
@@ -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
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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$')
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
72
common/djangoapps/terrain/stubs/start.py
Normal file
72
common/djangoapps/terrain/stubs/start.py
Normal file
@@ -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()
|
||||
@@ -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):
|
||||
|
||||
@@ -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': '<div></div>'})
|
||||
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': '<div></div>'}
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user