diff --git a/.gitignore b/.gitignore index 8fb170c30f..05e76c4caa 100644 --- a/.gitignore +++ b/.gitignore @@ -4,10 +4,12 @@ *.swp *.orig *.DS_Store +*.mo :2e_* :2e# .AppleDouble database.sqlite +requirements/private.txt courseware/static/js/mathjax/* flushdb.sh build @@ -21,7 +23,10 @@ reports/ *.egg-info Gemfile.lock .env/ +conf/locale/en/LC_MESSAGES/*.po +!messages.po lms/static/sass/*.css +lms/static/sass/application.scss cms/static/sass/*.css lms/lib/comment_client/python nosetests.xml @@ -31,3 +36,8 @@ cover_html/ chromedriver.log /nbproject ghostdriver.log +node_modules +.pip_download_cache/ +.prereqs_cache +autodeploy.properties +.ws_migrations_complete diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 253bae3686..0000000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "common/test/phantom-jasmine"] - path = common/test/phantom-jasmine - url = https://github.com/jcarver989/phantom-jasmine.git \ No newline at end of file diff --git a/.reviewboardrc b/.reviewboardrc new file mode 100644 index 0000000000..b79235a4a4 --- /dev/null +++ b/.reviewboardrc @@ -0,0 +1,2 @@ +REVIEWBOARD_URL = "https://rbcommons.com/s/edx/" +GUESS_FIELDS = True diff --git a/.ruby-gemset b/.ruby-gemset new file mode 100644 index 0000000000..77266c35f0 --- /dev/null +++ b/.ruby-gemset @@ -0,0 +1 @@ +edx-platform diff --git a/.tx/config b/.tx/config new file mode 100644 index 0000000000..540c4732af --- /dev/null +++ b/.tx/config @@ -0,0 +1,26 @@ +[main] +host = https://www.transifex.com + +[edx-studio.django-partial] +file_filter = conf/locale//LC_MESSAGES/django-partial.po +source_file = conf/locale/en/LC_MESSAGES/django-partial.po +source_lang = en +type = PO + +[edx-studio.djangojs] +file_filter = conf/locale//LC_MESSAGES/djangojs.po +source_file = conf/locale/en/LC_MESSAGES/djangojs.po +source_lang = en +type = PO + +[edx-studio.mako] +file_filter = conf/locale//LC_MESSAGES/mako.po +source_file = conf/locale/en/LC_MESSAGES/mako.po +source_lang = en +type = PO + +[edx-studio.messages] +file_filter = conf/locale//LC_MESSAGES/messages.po +source_file = conf/locale/en/LC_MESSAGES/messages.po +source_lang = en +type = PO diff --git a/AUTHORS b/AUTHORS new file mode 100644 index 0000000000..ca6e14b252 --- /dev/null +++ b/AUTHORS @@ -0,0 +1,75 @@ +Piotr Mitros +Kyle Fiedler +Ernie Park +Bridger Maxwell +Lyla Fischer +David Ormsbee +Chris Terman +Reda Lemeden +Anant Agarwal +Jean-Michel Claus +Calen Pennington +JM Van Thong +Prem Sichanugrist +Isaac Chuang +Galen Frechette +Edward Loveall +Matt Jankowski +John Jarvis +Victor Shnayder +Matthew Mongeau +Tony Kim +Arjun Singh +John Hess +Carlos Andrés Rocha +Mike Chen +Rocky Duan +Sidhanth Rao +Brittany Cheng +Dhaval Adjodah +Tom Giannattasio +Ibrahim Awwal +Sarina Canelake +Mark L. Chang +Dean Dieker +Tommy MacWilliam +Nate Hardison +Chris Dodge +Kevin Chugh +Ned Batchelder +Alexander Kryklia +Vik Paruchuri +Louis Sobel +Brian Wilson +Ashley Penney +Don Mitchell +Aaron Culich +Brian Talbot +Jay Zoldak +Valera Rozuvan +Diana Huang +Marco Morales +Christina Roberts +Robert Chirwa +Ed Zarecor +Deena Wang +Jean Manuel-Nater +Emily Zhang <1800.ehz.hang@gmail.com> +Jennifer Akana +Peter Baratta +Julian Arni +Arthur Barrett +Vasyl Nakvasiuk +Will Daly +James Tauber +Greg Price +Joe Blaylock +Sef Kloninger +Anto Stupak +David Adams +Steve Strassmann +Giulio Gratta +David Baumgold +Jason Bau +Frances Botsford +Slater Victoroff diff --git a/LICENSE.TXT b/LICENSE similarity index 100% rename from LICENSE.TXT rename to LICENSE diff --git a/README b/README deleted file mode 100644 index 2ed50ba063..0000000000 --- a/README +++ /dev/null @@ -1 +0,0 @@ -See doc/ for documentation. diff --git a/README.md b/README.md new file mode 100644 index 0000000000..ba3c99e6e2 --- /dev/null +++ b/README.md @@ -0,0 +1,171 @@ +This is the main edX platform which consists of LMS and Studio. + +See [code.edx.org](http://code.edx.org/) for other parts of the edX code base. + +Installation +============ + +There is a `scripts/create-dev-env.sh` that will attempt to set up a development +environment. + +If you want to better understand what the script is doing, keep reading. + +Directory Hierarchy +------------------- + +This code assumes that it is checked out in a directory that has three sibling +directories: `data` (used for XML course data), `db` (used to hold a +[sqlite](https://sqlite.org/) database), and `log` (used to hold logs). If you +clone the repository into a directory called `edx` inside of a directory +called `dev`, here's an example of how the directory hierarchy should look: + + * dev + \ + * data + * db + * log + * edx + \ + README.md + +Language Runtimes +----------------- +You'll need to be sure that you have Python 2.7, Ruby 1.9.3, and NodeJS +(latest stable) installed on your system. Some of these you can install +using your system's package manager: [homebrew](http://mxcl.github.io/homebrew/) +for Mac, [apt](http://wiki.debian.org/Apt) for Debian-based systems +(including Ubuntu), [rpm](http://www.rpm.org/) or [yum](http://yum.baseurl.org/) +for Red Hat based systems (including CentOS). + +If your system's package manager gives you the wrong version of a language +runtime, then you'll need to use a versioning tool to install the correct version. +Usually, you'll need to do this for Ruby: you can use +[`rbenv`](https://github.com/sstephenson/rbenv) or [`rvm`](https://rvm.io/), but +typically `rbenv` is simpler. For Python, you can use +[`pythonz`](http://saghul.github.io/pythonz/), +and for Node, you can use [`nvm`](https://github.com/creationix/nvm). + +Virtual Environments +-------------------- +Often, different projects will have conflicting dependencies: for example, two +projects depending on two different, incompatible versions of a library. Clearly, +you can't have both versions installed and used on your machine simultaneously. +Virtual environments were created to solve this problem: by installing libraries +into an isolated environment, only projects that live inside the environment +will be able to see and use those libraries. Got incompatible dependencies? Use +different virtual environments, and your problem is solved. + +Remember, each language has a different implementation. Python has +[`virtualenv`](http://www.virtualenv.org/), Ruby has +[`bundler`](http://gembundler.com/), and Node's virtual environment support +is built into [`npm`](https://npmjs.org/), its library management tool. +For each language, decide if you want to use a virtual environment, or if you +want to install all the language dependencies globally (and risk conflicts). +I suggest you start with installing things globally until and unless things +break; you can always switch over to a virtual environment later on. + +Language Packages +----------------- +The Python libraries we use are listed in `requirements.txt`. The Ruby libraries +we use are listed in `Gemfile`. The Node libraries we use are listed in +`packages.json`. Python has a library installer called +[`pip`](http://www.pip-installer.org/), Ruby has a library installer called +[`gem`](https://rubygems.org/) (or `bundle` if you're using a virtual +environment), and Node has a library installer called +[`npm`](https://npmjs.org/). +Once you've got your languages and virtual environments set up, install +the libraries like so: + + $ pip install -r requirements/edx/pre.txt + $ pip install -r requirements/edx/base.txt + $ pip install -r requirements/edx/post.txt + $ bundle install + $ npm install + +You can also use [`rake`](http://rake.rubyforge.org/) to get all of the prerequisites (or to update) +them if they've changed + + $ rake install_prereqs + +Other Dependencies +------------------ +You'll also need to install [MongoDB](http://www.mongodb.org/), since our +application uses it in addition to sqlite. You can install it through your +system package manager, and I suggest that you configure it to start +automatically when you boot up your system, so that you never have to worry +about it again. For Mac, use +[`launchd`](https://developer.apple.com/library/mac/documentation/Darwin/Reference/ManPages/man8/launchd.8.html) +(running `brew info mongodb` will give you some commands you can copy-paste.) +For Linux, you can use [`upstart`](http://upstart.ubuntu.com/), `chkconfig`, +or any other process management tool. + +Configuring Your Project +------------------------ +We use [`rake`](http://rake.rubyforge.org/) to execute common tasks in our +project. The `rake` tasks are defined in the `rakefile`, or you can run `rake -T` +to view a summary. + +Before you run your project, you need to create a sqlite database, create +tables in that database, run database migrations, and populate templates for +CMS templates. Fortunately, `rake` will do all of this for you! Just run: + + $ rake django-admin[syncdb] + $ rake django-admin[migrate] + $ rake django-admin[update_templates] + +If you are running these commands using the [`zsh`](http://www.zsh.org/) shell, +zsh will assume that you are doing +[shell globbing](https://en.wikipedia.org/wiki/Glob_(programming)), search for +a file in your directory named `django-adminsyncdb` or `django-adminmigrate`, +and fail. To fix this, just surround the argument with quotation marks, so that +you're running `rake "django-admin[syncdb]"`. + +Run Your Project +---------------- +edX has two components: Studio, the course authoring system; and the LMS +(learning management system) used by students. These two systems communicate +through the MongoDB database, which stores course information. + +To run Studio, run: + + $ rake cms + +To run the LMS, run: + + $ rake lms[cms.dev] + +Studio runs on port 8001, while LMS runs on port 8000, so you can run both of +these commands simultaneously, using two different terminal windows. To view +Studio, visit `127.0.0.1:8001` in your web browser; to view the LMS, visit +`127.0.0.1:8000`. + +There's also an older version of the LMS that saves its information in XML files +in the `data` directory, instead of in Mongo. To run this older version, run: + + $ rake lms + +License +------- + +The code in this repository is licensed under version 3 of the AGPL unless +otherwise noted. + +Please see ``LICENSE.txt`` for details. + +How to Contribute +----------------- + +Contributions are very welcome. The easiest way is to fork this repo, and then +make a pull request from your fork. The first time you make a pull request, you +may be asked to sign a Contributor Agreement. + +Reporting Security Issues +------------------------- + +Please do not report security issues in public. Please email security@edx.org + +Mailing List and IRC Channel +---------------------------- + +You can discuss this code on the [edx-code Google Group](https://groups.google.com/forum/#!forum/edx-code) or in the +`edx-code` IRC channel on Freenode. diff --git a/cms/djangoapps/auth/authz.py b/cms/djangoapps/auth/authz.py index 281e3f46b2..71b5e97bc2 100644 --- a/cms/djangoapps/auth/authz.py +++ b/cms/djangoapps/auth/authz.py @@ -1,6 +1,3 @@ -import logging -import sys - from django.contrib.auth.models import User, Group from django.core.exceptions import PermissionDenied @@ -131,7 +128,7 @@ def remove_user_from_course_group(caller, user, location, role): raise PermissionDenied # see if the user is actually in that role, if not then we don't have to do anything - if is_user_in_course_group_role(user, location, role) == True: + if is_user_in_course_group_role(user, location, role): groupname = get_course_groupname_for_role(location, role) group = Group.objects.get(name=groupname) diff --git a/cms/djangoapps/contentstore/course_info_model.py b/cms/djangoapps/contentstore/course_info_model.py index 589db4ac56..ada3873992 100644 --- a/cms/djangoapps/contentstore/course_info_model.py +++ b/cms/djangoapps/contentstore/course_info_model.py @@ -97,8 +97,7 @@ def update_course_updates(location, update, passed_id=None): if (len(new_html_parsed) == 1): content = new_html_parsed[0].tail else: - content = "\n".join([html.tostring(ele) - for ele in new_html_parsed[1:]]) + content = "\n".join([html.tostring(ele) for ele in new_html_parsed[1:]]) return {"id": passed_id, "date": update['date'], diff --git a/cms/djangoapps/contentstore/features/advanced-settings.feature b/cms/djangoapps/contentstore/features/advanced-settings.feature index 558294e890..6f6cc50702 100644 --- a/cms/djangoapps/contentstore/features/advanced-settings.feature +++ b/cms/djangoapps/contentstore/features/advanced-settings.feature @@ -11,6 +11,8 @@ Feature: Advanced (manual) course policy Given I am on the Advanced Course Settings page in Studio Then the settings are alphabetized + # Skipped because Ubuntu ChromeDriver cannot click notification "Cancel" + @skip Scenario: Test cancel editing key value Given I am on the Advanced Course Settings page in Studio When I edit the value of a policy key @@ -19,6 +21,8 @@ Feature: Advanced (manual) course policy And I reload the page Then the policy key value is unchanged + # Skipped because Ubuntu ChromeDriver cannot click notification "Save" + @skip Scenario: Test editing key value Given I am on the Advanced Course Settings page in Studio When I edit the value of a policy key and save @@ -26,6 +30,8 @@ Feature: Advanced (manual) course policy And I reload the page Then the policy key value is changed + # Skipped because Ubuntu ChromeDriver cannot edit CodeMirror input + @skip Scenario: Test how multi-line input appears Given I am on the Advanced Course Settings page in Studio When I create a JSON object as a value @@ -33,6 +39,8 @@ Feature: Advanced (manual) course policy And I reload the page Then it is displayed as formatted + # Skipped because Ubuntu ChromeDriver cannot edit CodeMirror input + @skip Scenario: Test automatic quoting of non-JSON values Given I am on the Advanced Course Settings page in Studio When I create a non-JSON value not in quotes diff --git a/cms/djangoapps/contentstore/features/advanced-settings.py b/cms/djangoapps/contentstore/features/advanced-settings.py index 6fb102faea..3acebecac8 100644 --- a/cms/djangoapps/contentstore/features/advanced-settings.py +++ b/cms/djangoapps/contentstore/features/advanced-settings.py @@ -3,10 +3,7 @@ from lettuce import world, step from common import * -import time -from terrain.steps import reload_the_page - -from nose.tools import assert_true, assert_false, assert_equal +from nose.tools import assert_false, assert_equal """ http://selenium.googlecode.com/svn/trunk/docs/api/py/webdriver/selenium.webdriver.common.keys.html @@ -18,13 +15,11 @@ VALUE_CSS = 'textarea.json' DISPLAY_NAME_KEY = "display_name" DISPLAY_NAME_VALUE = '"Robot Super Course"' -############### ACTIONS #################### +############### ACTIONS #################### @step('I select the Advanced Settings$') def i_select_advanced_settings(step): - expand_icon_css = 'li.nav-course-settings i.icon-expand' - if world.browser.is_element_present_by_css(expand_icon_css): - world.css_click(expand_icon_css) + world.click_course_settings() link_css = 'li.nav-course-settings-advanced a' world.css_click(link_css) @@ -38,7 +33,7 @@ def i_am_on_advanced_course_settings(step): @step(u'I press the "([^"]*)" notification button$') def press_the_notification_button(step, name): css = 'a.%s-button' % name.lower() - world.css_click_at(css) + world.css_click(css) @step(u'I edit the value of a policy key$') @@ -52,7 +47,7 @@ def edit_the_value_of_a_policy_key(step): @step(u'I edit the value of a policy key and save$') -def edit_the_value_of_a_policy_key(step): +def edit_the_value_of_a_policy_key_and_save(step): change_display_name_value(step, '"foo"') @@ -90,7 +85,7 @@ def it_is_formatted(step): @step('it is displayed as a string') -def it_is_formatted(step): +def it_is_displayed_as_string(step): assert_policy_entries([DISPLAY_NAME_KEY], ['"quote me"']) diff --git a/cms/djangoapps/contentstore/features/checklists.feature b/cms/djangoapps/contentstore/features/checklists.feature index bccb80b8d7..3767144c99 100644 --- a/cms/djangoapps/contentstore/features/checklists.feature +++ b/cms/djangoapps/contentstore/features/checklists.feature @@ -21,4 +21,3 @@ Feature: Course checklists Given I have opened Checklists When I select a link to help page Then I am brought to the help page in a new window - diff --git a/cms/djangoapps/contentstore/features/checklists.py b/cms/djangoapps/contentstore/features/checklists.py index dc399f5fac..9552d35036 100644 --- a/cms/djangoapps/contentstore/features/checklists.py +++ b/cms/djangoapps/contentstore/features/checklists.py @@ -2,16 +2,15 @@ #pylint: disable=W0621 from lettuce import world, step -from nose.tools import assert_true, assert_equal +from nose.tools import assert_true, assert_equal, assert_in from terrain.steps import reload_the_page from selenium.common.exceptions import StaleElementReferenceException + ############### ACTIONS #################### @step('I select Checklists from the Tools menu$') def i_select_checklists(step): - expand_icon_css = 'li.nav-course-tools i.icon-expand' - if world.browser.is_element_present_by_css(expand_icon_css): - world.css_click(expand_icon_css) + world.click_tools() link_css = 'li.nav-course-tools-checklists a' world.css_click(link_css) @@ -62,7 +61,7 @@ def i_select_a_link_to_the_course_outline(step): @step('I am brought to the course outline page$') def i_am_brought_to_course_outline(step): - assert_equal('Course Outline', world.css_find('.outline .title-1')[0].text) + assert_in('Course Outline', world.css_find('.outline .page-header')[0].text) assert_equal(1, len(world.browser.windows)) @@ -88,8 +87,6 @@ def i_am_brought_to_help_page_in_new_window(step): assert_equal('http://help.edge.edx.org/', world.browser.url) - - ############### HELPER METHODS #################### def verifyChecklist2Status(completed, total, percentage): def verify_count(driver): @@ -106,9 +103,11 @@ def verifyChecklist2Status(completed, total, percentage): def toggleTask(checklist, task): - world.css_click('#course-checklist' + str(checklist) +'-task' + str(task)) + world.css_click('#course-checklist' + str(checklist) + '-task' + str(task)) +# TODO: figure out a way to do this in phantom and firefox +# For now we will mark the scenerios that use this method as skipped def clickActionLink(checklist, task, actionText): # toggle checklist item to make sure that the link button is showing toggleTask(checklist, task) @@ -120,4 +119,3 @@ def clickActionLink(checklist, task, actionText): world.wait_for(verify_action_link_text) action_link.click() - diff --git a/cms/djangoapps/contentstore/features/common.py b/cms/djangoapps/contentstore/features/common.py index afb38c3f9e..96b840ae96 100644 --- a/cms/djangoapps/contentstore/features/common.py +++ b/cms/djangoapps/contentstore/features/common.py @@ -5,8 +5,6 @@ from lettuce import world, step from nose.tools import assert_true from nose.tools import assert_equal -from xmodule.modulestore.django import _MODULESTORES, modulestore -from xmodule.templates import update_templates from auth.authz import get_user_by_email from selenium.webdriver.common.keys import Keys @@ -50,31 +48,31 @@ def i_press_the_category_delete_icon(step, category): @step('I have opened a new course in Studio$') def i_have_opened_a_new_course(step): + open_new_course() + + +####### HELPER FUNCTIONS ############## +def open_new_course(): world.clear_courses() log_into_studio() create_a_course() -####### HELPER FUNCTIONS ############## def create_studio_user( uname='robot', email='robot+studio@edx.org', password='test', is_staff=False): - studio_user = world.UserFactory.build( + studio_user = world.UserFactory( username=uname, email=email, password=password, is_staff=is_staff) - studio_user.set_password(password) - studio_user.save() registration = world.RegistrationFactory(user=studio_user) registration.register(studio_user) registration.activate() - user_profile = world.UserProfileFactory(user=studio_user) - def fill_in_course_info( name='Robot Super Course', @@ -153,4 +151,13 @@ def set_date_and_time(date_css, desired_date, time_css, desired_time): world.css_fill(time_css, desired_time) e = world.css_find(time_css).first e._element.send_keys(Keys.TAB) - time.sleep(float(1)) + time.sleep(float(1)) + + +@step('I have created a Video component$') +def i_created_a_video_component(step): + world.create_component_instance( + step, '.large-video-icon', + 'i4x://edx/templates/video/default', + '.xmodule_VideoModule' + ) diff --git a/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py b/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py new file mode 100644 index 0000000000..4c674dc34c --- /dev/null +++ b/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py @@ -0,0 +1,85 @@ +# disable missing docstring +#pylint: disable=C0111 + +from lettuce import world +from nose.tools import assert_equal +from terrain.steps import reload_the_page + + +@world.absorb +def create_component_instance(step, component_button_css, instance_id, expected_css): + click_new_component_button(step, component_button_css) + click_component_from_menu(instance_id, expected_css) + + +@world.absorb +def click_new_component_button(step, component_button_css): + step.given('I have opened a new course section in Studio') + step.given('I have added a new subsection') + step.given('I expand the first section') + world.css_click('a.new-unit-item') + world.css_click(component_button_css) + + +@world.absorb +def click_component_from_menu(instance_id, expected_css): + elem_css = "a[data-location='%s']" % instance_id + assert_equal(1, len(world.css_find(elem_css))) + world.css_click(elem_css) + assert_equal(1, len(world.css_find(expected_css))) + +@world.absorb +def edit_component_and_select_settings(): + world.css_click('a.edit-button') + world.css_click('#settings-mode') + + +@world.absorb +def verify_setting_entry(setting, display_name, value, explicitly_set): + assert_equal(display_name, setting.find_by_css('.setting-label')[0].value) + assert_equal(value, setting.find_by_css('.setting-input')[0].value) + settingClearButton = setting.find_by_css('.setting-clear')[0] + assert_equal(explicitly_set, settingClearButton.has_class('active')) + assert_equal(not explicitly_set, settingClearButton.has_class('inactive')) + + +@world.absorb +def verify_all_setting_entries(expected_entries): + settings = world.browser.find_by_css('.wrapper-comp-setting') + assert_equal(len(expected_entries), len(settings)) + for (counter, setting) in enumerate(settings): + world.verify_setting_entry( + setting, expected_entries[counter][0], + expected_entries[counter][1], expected_entries[counter][2] + ) + + +@world.absorb +def save_component_and_reopen(step): + world.css_click("a.save-button") + # We have a known issue that modifications are still shown within the edit window after cancel (though) + # they are not persisted. Refresh the browser to make sure the changes WERE persisted after Save. + reload_the_page(step) + edit_component_and_select_settings() + + +@world.absorb +def cancel_component(step): + world.css_click("a.cancel-button") + # We have a known issue that modifications are still shown within the edit window after cancel (though) + # they are not persisted. Refresh the browser to make sure the changes were not persisted. + reload_the_page(step) + + +@world.absorb +def revert_setting_entry(label): + get_setting_entry(label).find_by_css('.setting-clear')[0].click() + + +@world.absorb +def get_setting_entry(label): + settings = world.browser.find_by_css('.wrapper-comp-setting') + for setting in settings: + if setting.find_by_css('.setting-label')[0].value == label: + return setting + return None diff --git a/cms/djangoapps/contentstore/features/course-settings.py b/cms/djangoapps/contentstore/features/course-settings.py index d69266b7de..bd86fff9b7 100644 --- a/cms/djangoapps/contentstore/features/course-settings.py +++ b/cms/djangoapps/contentstore/features/course-settings.py @@ -25,9 +25,7 @@ DEFAULT_TIME = "00:00" ############### ACTIONS #################### @step('I select Schedule and Details$') def test_i_select_schedule_and_details(step): - expand_icon_css = 'li.nav-course-settings i.icon-expand' - if world.browser.is_element_present_by_css(expand_icon_css): - world.css_click(expand_icon_css) + world.click_course_settings() link_css = 'li.nav-course-settings-schedule a' world.css_click(link_css) diff --git a/cms/djangoapps/contentstore/features/courses.py b/cms/djangoapps/contentstore/features/courses.py index 5da7720945..aa2e9d68f8 100644 --- a/cms/djangoapps/contentstore/features/courses.py +++ b/cms/djangoapps/contentstore/features/courses.py @@ -62,4 +62,4 @@ def i_am_on_tab(step, tab_name): @step('I see a link for adding a new section$') def i_see_new_section_link(step): link_css = 'a.new-courseware-section-button' - assert world.css_has_text(link_css, '+ New Section') + assert world.css_has_text(link_css, 'New Section') diff --git a/cms/djangoapps/contentstore/features/discussion-editor.feature b/cms/djangoapps/contentstore/features/discussion-editor.feature new file mode 100644 index 0000000000..24683c3297 --- /dev/null +++ b/cms/djangoapps/contentstore/features/discussion-editor.feature @@ -0,0 +1,13 @@ +Feature: Discussion Component Editor + As a course author, I want to be able to create discussion components. + + Scenario: User can view metadata + Given I have created a Discussion Tag + And I edit and select Settings + Then I see three alphabetized settings and their expected values + + Scenario: User can modify display name + Given I have created a Discussion Tag + And I edit and select Settings + Then I can modify the display name + And my display name change is persisted on save diff --git a/cms/djangoapps/contentstore/features/discussion-editor.py b/cms/djangoapps/contentstore/features/discussion-editor.py new file mode 100644 index 0000000000..aced4c2c88 --- /dev/null +++ b/cms/djangoapps/contentstore/features/discussion-editor.py @@ -0,0 +1,23 @@ +# disable missing docstring +#pylint: disable=C0111 + +from lettuce import world, step + + +@step('I have created a Discussion Tag$') +def i_created_discussion_tag(step): + world.create_component_instance( + step, '.large-discussion-icon', + 'i4x://edx/templates/discussion/Discussion_Tag', + '.xmodule_DiscussionModule' + ) + + +@step('I see three alphabetized settings and their expected values$') +def i_see_only_the_settings_and_values(step): + world.verify_all_setting_entries( + [ + ['Category', "Week 1", True], + ['Display Name', "Discussion Tag", True], + ['Subcategory', "Topic-Level Student-Visible Label", True] + ]) diff --git a/cms/djangoapps/contentstore/features/html-editor.feature b/cms/djangoapps/contentstore/features/html-editor.feature new file mode 100644 index 0000000000..6cd455d681 --- /dev/null +++ b/cms/djangoapps/contentstore/features/html-editor.feature @@ -0,0 +1,13 @@ +Feature: HTML Editor + As a course author, I want to be able to create HTML blocks. + + Scenario: User can view metadata + Given I have created a Blank HTML Page + And I edit and select Settings + Then I see only the HTML display name setting + + Scenario: User can modify display name + Given I have created a Blank HTML Page + And I edit and select Settings + Then I can modify the display name + And my display name change is persisted on save diff --git a/cms/djangoapps/contentstore/features/html-editor.py b/cms/djangoapps/contentstore/features/html-editor.py new file mode 100644 index 0000000000..054c0ea642 --- /dev/null +++ b/cms/djangoapps/contentstore/features/html-editor.py @@ -0,0 +1,17 @@ +# disable missing docstring +#pylint: disable=C0111 + +from lettuce import world, step + + +@step('I have created a Blank HTML Page$') +def i_created_blank_html_page(step): + world.create_component_instance( + step, '.large-html-icon', 'i4x://edx/templates/html/Blank_HTML_Page', + '.xmodule_HtmlModule' + ) + + +@step('I see only the HTML display name setting$') +def i_see_only_the_html_display_name(step): + world.verify_all_setting_entries([['Display Name', "Blank HTML Page", True]]) diff --git a/cms/djangoapps/contentstore/features/problem-editor.feature b/cms/djangoapps/contentstore/features/problem-editor.feature new file mode 100644 index 0000000000..6ed8c1619b --- /dev/null +++ b/cms/djangoapps/contentstore/features/problem-editor.feature @@ -0,0 +1,67 @@ +Feature: Problem Editor + As a course author, I want to be able to create problems and edit their settings. + + Scenario: User can view metadata + Given I have created a Blank Common Problem + And I edit and select Settings + Then I see five alphabetized settings and their expected values + And Edit High Level Source is not visible + + Scenario: User can modify String values + Given I have created a Blank Common Problem + And I edit and select Settings + Then I can modify the display name + And my display name change is persisted on save + + Scenario: User can specify special characters in String values + Given I have created a Blank Common Problem + And I edit and select Settings + Then I can specify special characters in the display name + And my special characters and persisted on save + + Scenario: User can revert display name to unset + Given I have created a Blank Common Problem + And I edit and select Settings + Then I can revert the display name to unset + And my display name is unset on save + + Scenario: User can select values in a Select + Given I have created a Blank Common Problem + And I edit and select Settings + Then I can select Per Student for Randomization + And my change to randomization is persisted + And I can revert to the default value for randomization + + Scenario: User can modify float input values + Given I have created a Blank Common Problem + And I edit and select Settings + Then I can set the weight to "3.5" + And my change to weight is persisted + And I can revert to the default value of unset for weight + + Scenario: User cannot type letters in float number field + Given I have created a Blank Common Problem + And I edit and select Settings + Then if I set the weight to "abc", it remains unset + + Scenario: User cannot type decimal values integer number field + Given I have created a Blank Common Problem + And I edit and select Settings + Then if I set the max attempts to "2.34", it displays initially as "234", and is persisted as "234" + + Scenario: User cannot type out of range values in an integer number field + Given I have created a Blank Common Problem + And I edit and select Settings + Then if I set the max attempts to "-3", it displays initially as "-3", and is persisted as "1" + + Scenario: Settings changes are not saved on Cancel + Given I have created a Blank Common Problem + And I edit and select Settings + Then I can set the weight to "3.5" + And I can modify the display name + Then If I press Cancel my changes are not persisted + + Scenario: Edit High Level source is available for LaTeX problem + Given I have created a LaTeX Problem + And I edit and select Settings + Then Edit High Level Source is visible diff --git a/cms/djangoapps/contentstore/features/problem-editor.py b/cms/djangoapps/contentstore/features/problem-editor.py new file mode 100644 index 0000000000..5dfcf55046 --- /dev/null +++ b/cms/djangoapps/contentstore/features/problem-editor.py @@ -0,0 +1,187 @@ +# disable missing docstring +#pylint: disable=C0111 + +from lettuce import world, step +from nose.tools import assert_equal + +DISPLAY_NAME = "Display Name" +MAXIMUM_ATTEMPTS = "Maximum Attempts" +PROBLEM_WEIGHT = "Problem Weight" +RANDOMIZATION = 'Randomization' +SHOW_ANSWER = "Show Answer" + + +############### ACTIONS #################### +@step('I have created a Blank Common Problem$') +def i_created_blank_common_problem(step): + world.create_component_instance( + step, + '.large-problem-icon', + 'i4x://edx/templates/problem/Blank_Common_Problem', + '.xmodule_CapaModule' + ) + + +@step('I edit and select Settings$') +def i_edit_and_select_settings(step): + world.edit_component_and_select_settings() + + +@step('I see five alphabetized settings and their expected values$') +def i_see_five_settings_with_values(step): + world.verify_all_setting_entries( + [ + [DISPLAY_NAME, "Blank Common Problem", True], + [MAXIMUM_ATTEMPTS, "", False], + [PROBLEM_WEIGHT, "", False], + [RANDOMIZATION, "Never", True], + [SHOW_ANSWER, "Finished", True] + ]) + + +@step('I can modify the display name') +def i_can_modify_the_display_name(step): + world.get_setting_entry(DISPLAY_NAME).find_by_css('.setting-input')[0].fill('modified') + verify_modified_display_name() + + +@step('my display name change is persisted on save') +def my_display_name_change_is_persisted_on_save(step): + world.save_component_and_reopen(step) + verify_modified_display_name() + + +@step('I can specify special characters in the display name') +def i_can_modify_the_display_name_with_special_chars(step): + world.get_setting_entry(DISPLAY_NAME).find_by_css('.setting-input')[0].fill("updated ' \" &") + verify_modified_display_name_with_special_chars() + + +@step('my special characters and persisted on save') +def special_chars_persisted_on_save(step): + world.save_component_and_reopen(step) + verify_modified_display_name_with_special_chars() + + +@step('I can revert the display name to unset') +def can_revert_display_name_to_unset(step): + world.revert_setting_entry(DISPLAY_NAME) + verify_unset_display_name() + + +@step('my display name is unset on save') +def my_display_name_is_persisted_on_save(step): + world.save_component_and_reopen(step) + verify_unset_display_name() + + +@step('I can select Per Student for Randomization') +def i_can_select_per_student_for_randomization(step): + world.browser.select(RANDOMIZATION, "Per Student") + verify_modified_randomization() + + +@step('my change to randomization is persisted') +def my_change_to_randomization_is_persisted(step): + world.save_component_and_reopen(step) + verify_modified_randomization() + + +@step('I can revert to the default value for randomization') +def i_can_revert_to_default_for_randomization(step): + world.revert_setting_entry(RANDOMIZATION) + world.save_component_and_reopen(step) + world.verify_setting_entry(world.get_setting_entry(RANDOMIZATION), RANDOMIZATION, "Always", False) + + +@step('I can set the weight to "(.*)"?') +def i_can_set_weight(step, weight): + set_weight(weight) + verify_modified_weight() + + +@step('my change to weight is persisted') +def my_change_to_weight_is_persisted(step): + world.save_component_and_reopen(step) + verify_modified_weight() + + +@step('I can revert to the default value of unset for weight') +def i_can_revert_to_default_for_unset_weight(step): + world.revert_setting_entry(PROBLEM_WEIGHT) + world.save_component_and_reopen(step) + world.verify_setting_entry(world.get_setting_entry(PROBLEM_WEIGHT), PROBLEM_WEIGHT, "", False) + + +@step('if I set the weight to "(.*)", it remains unset') +def set_the_weight_to_abc(step, bad_weight): + set_weight(bad_weight) + # We show the clear button immediately on type, hence the "True" here. + world.verify_setting_entry(world.get_setting_entry(PROBLEM_WEIGHT), PROBLEM_WEIGHT, "", True) + world.save_component_and_reopen(step) + # But no change was actually ever sent to the model, so on reopen, explicitly_set is False + world.verify_setting_entry(world.get_setting_entry(PROBLEM_WEIGHT), PROBLEM_WEIGHT, "", False) + + +@step('if I set the max attempts to "(.*)", it displays initially as "(.*)", and is persisted as "(.*)"') +def set_the_max_attempts(step, max_attempts_set, max_attempts_displayed, max_attempts_persisted): + world.get_setting_entry(MAXIMUM_ATTEMPTS).find_by_css('.setting-input')[0].fill(max_attempts_set) + world.verify_setting_entry(world.get_setting_entry(MAXIMUM_ATTEMPTS), MAXIMUM_ATTEMPTS, max_attempts_displayed, True) + world.save_component_and_reopen(step) + world.verify_setting_entry(world.get_setting_entry(MAXIMUM_ATTEMPTS), MAXIMUM_ATTEMPTS, max_attempts_persisted, True) + + +@step('Edit High Level Source is not visible') +def edit_high_level_source_not_visible(step): + verify_high_level_source(step, False) + + +@step('Edit High Level Source is visible') +def edit_high_level_source_visible(step): + verify_high_level_source(step, True) + + +@step('If I press Cancel my changes are not persisted') +def cancel_does_not_save_changes(step): + world.cancel_component(step) + step.given("I edit and select Settings") + step.given("I see five alphabetized settings and their expected values") + + +@step('I have created a LaTeX Problem') +def create_latex_problem(step): + world.click_new_component_button(step, '.large-problem-icon') + # Go to advanced tab (waiting for the tab to be visible) + world.css_find('#ui-id-2') + world.css_click('#ui-id-2') + world.click_component_from_menu("i4x://edx/templates/problem/Problem_Written_in_LaTeX", '.xmodule_CapaModule') + + +def verify_high_level_source(step, visible): + assert_equal(visible, world.is_css_present('.launch-latex-compiler')) + world.cancel_component(step) + assert_equal(visible, world.is_css_present('.upload-button')) + + +def verify_modified_weight(): + world.verify_setting_entry(world.get_setting_entry(PROBLEM_WEIGHT), PROBLEM_WEIGHT, "3.5", True) + + +def verify_modified_randomization(): + world.verify_setting_entry(world.get_setting_entry(RANDOMIZATION), RANDOMIZATION, "Per Student", True) + + +def verify_modified_display_name(): + world.verify_setting_entry(world.get_setting_entry(DISPLAY_NAME), DISPLAY_NAME, 'modified', True) + + +def verify_modified_display_name_with_special_chars(): + world.verify_setting_entry(world.get_setting_entry(DISPLAY_NAME), DISPLAY_NAME, "updated ' \" &", True) + + +def verify_unset_display_name(): + world.verify_setting_entry(world.get_setting_entry(DISPLAY_NAME), DISPLAY_NAME, '', False) + + +def set_weight(weight): + world.get_setting_entry(PROBLEM_WEIGHT).find_by_css('.setting-input')[0].fill(weight) diff --git a/cms/djangoapps/contentstore/features/section.feature b/cms/djangoapps/contentstore/features/section.feature index 08d38367bc..236cf501fc 100644 --- a/cms/djangoapps/contentstore/features/section.feature +++ b/cms/djangoapps/contentstore/features/section.feature @@ -26,7 +26,8 @@ Feature: Create Section And I save a new section release date Then the section release date is updated - @skip-phantom + # Skipped because Ubuntu ChromeDriver hangs on alert + @skip Scenario: Delete section Given I have opened a new course in Studio And I have added a new section diff --git a/cms/djangoapps/contentstore/features/section.py b/cms/djangoapps/contentstore/features/section.py index 59c5a37b33..9a896d8ebe 100644 --- a/cms/djangoapps/contentstore/features/section.py +++ b/cms/djangoapps/contentstore/features/section.py @@ -62,7 +62,7 @@ def i_click_to_edit_section_name(step): @step('I see the complete section name with a quote in the editor$') def i_see_complete_section_name_with_quote_in_editor(step): - css = '.edit-section-name' + css = '.section-name-edit input[type=text]' assert world.is_css_present(css) assert_equal(world.browser.find_by_css(css).value, 'Section with "Quote"') diff --git a/cms/djangoapps/contentstore/features/signup.py b/cms/djangoapps/contentstore/features/signup.py index 6ca358183b..398f8d074d 100644 --- a/cms/djangoapps/contentstore/features/signup.py +++ b/cms/djangoapps/contentstore/features/signup.py @@ -18,10 +18,7 @@ def i_fill_in_the_registration_form(step): @step('I press the Create My Account button on the registration form$') def i_press_the_button_on_the_registration_form(step): submit_css = 'form#register_form button#submit' - # Workaround for click not working on ubuntu - # for some unknown reason. - e = world.css_find(submit_css) - e.type(' ') + world.css_click(submit_css) @step('I should see be on the studio home page$') diff --git a/cms/djangoapps/contentstore/features/studio-overview-togglesection.feature b/cms/djangoapps/contentstore/features/studio-overview-togglesection.feature index 762dea6838..c9f5b43dfb 100644 --- a/cms/djangoapps/contentstore/features/studio-overview-togglesection.feature +++ b/cms/djangoapps/contentstore/features/studio-overview-togglesection.feature @@ -1,32 +1,33 @@ Feature: Overview Toggle Section In order to quickly view the details of a course's section or to scan the inventory of sections - As a course author - I want to toggle the visibility of each section's subsection details in the overview listing + As a course author + I want to toggle the visibility of each section's subsection details in the overview listing Scenario: The default layout for the overview page is to show sections in expanded view Given I have a course with multiple sections - When I navigate to the course overview page - Then I see the "Collapse All Sections" link - And all sections are expanded + When I navigate to the course overview page + Then I see the "Collapse All Sections" link + And all sections are expanded Scenario: Expand /collapse for a course with no sections Given I have a course with no sections - When I navigate to the course overview page - Then I do not see the "Collapse All Sections" link + When I navigate to the course overview page + Then I do not see the "Collapse All Sections" link Scenario: Collapse link appears after creating first section of a course Given I have a course with no sections - When I navigate to the course overview page - And I add a section - Then I see the "Collapse All Sections" link - And all sections are expanded + When I navigate to the course overview page + And I add a section + Then I see the "Collapse All Sections" link + And all sections are expanded - @skip-phantom + # Skipped because Ubuntu ChromeDriver hangs on alert + @skip Scenario: Collapse link is not removed after last section of a course is deleted Given I have a course with 1 section - And I navigate to the course overview page - When I press the "section" delete icon - And I confirm the alert + And I navigate to the course overview page + When I press the "section" delete icon + And I confirm the alert Then I see the "Collapse All Sections" link Scenario: Collapsing all sections when all sections are expanded @@ -57,4 +58,4 @@ Feature: Overview Toggle Section When I expand the first section And I click the "Expand All Sections" link Then I see the "Collapse All Sections" link - And all sections are expanded \ No newline at end of file + And all sections are expanded diff --git a/cms/djangoapps/contentstore/features/subsection.feature b/cms/djangoapps/contentstore/features/subsection.feature index cc3b2b1cbb..8bb12467ff 100644 --- a/cms/djangoapps/contentstore/features/subsection.feature +++ b/cms/djangoapps/contentstore/features/subsection.feature @@ -3,13 +3,13 @@ Feature: Create Subsection As a course author I want to create and edit subsections - Scenario: Add a new subsection to a section + Scenario: Add a new subsection to a section Given I have opened a new course section in Studio When I click the New Subsection link And I enter the subsection name and click save Then I see my subsection on the Courseware page - Scenario: Add a new subsection (with a name containing a quote) to a section (bug #216) + Scenario: Add a new subsection (with a name containing a quote) to a section (bug #216) Given I have opened a new course section in Studio When I click the New Subsection link And I enter a subsection name with a quote and click save @@ -17,7 +17,7 @@ Feature: Create Subsection And I click to edit the subsection name Then I see the complete subsection name with a quote in the editor - Scenario: Assign grading type to a subsection and verify it is still shown after refresh (bug #258) + Scenario: Assign grading type to a subsection and verify it is still shown after refresh (bug #258) Given I have opened a new course section in Studio And I have added a new subsection And I mark it as Homework @@ -25,20 +25,19 @@ Feature: Create Subsection And I reload the page Then I see it marked as Homework - Scenario: Set a due date in a different year (bug #256) + Scenario: Set a due date in a different year (bug #256) Given I have opened a new subsection in Studio And I have set a release date and due date in different years Then I see the correct dates And I reload the page Then I see the correct dates - @skip-phantom - Scenario: Delete a subsection + # Skipped because Ubuntu ChromeDriver hangs on alert + @skip + Scenario: Delete a subsection Given I have opened a new course section in Studio And I have added a new subsection And I see my subsection on the Courseware page When I press the "subsection" delete icon And I confirm the alert Then the subsection does not exist - - diff --git a/cms/djangoapps/contentstore/features/subsection.py b/cms/djangoapps/contentstore/features/subsection.py index f9e5b52bb2..1134e53280 100644 --- a/cms/djangoapps/contentstore/features/subsection.py +++ b/cms/djangoapps/contentstore/features/subsection.py @@ -10,9 +10,7 @@ from nose.tools import assert_equal @step('I have opened a new course section in Studio$') def i_have_opened_a_new_course_section(step): - world.clear_courses() - log_into_studio() - create_a_course() + open_new_course() add_section() @@ -63,14 +61,6 @@ def test_have_set_dates_in_different_years(step): set_date_and_time('input#due_date', '01/02/2012', 'input#due_time', '04:00') -@step('I see the correct dates$') -def i_see_the_correct_dates(step): - assert_equal('12/25/2011', world.css_find('input#start_date').first.value) - assert_equal('03:00', world.css_find('input#start_time').first.value) - assert_equal('01/02/2012', world.css_find('input#due_date').first.value) - assert_equal('04:00', world.css_find('input#due_time').first.value) - - @step('I mark it as Homework$') def i_mark_it_as_homework(step): world.css_click('a.menu-toggle') @@ -101,8 +91,20 @@ def the_subsection_does_not_exist(step): assert world.browser.is_element_not_present_by_css(css) +@step('I see the correct dates$') +def i_see_the_correct_dates(step): + assert_equal('12/25/2011', get_date('input#start_date')) + assert_equal('03:00', get_date('input#start_time')) + assert_equal('01/02/2012', get_date('input#due_date')) + assert_equal('04:00', get_date('input#due_time')) + + ############ HELPER METHODS ################### +def get_date(css): + return world.css_find(css).first.value.strip() + + def save_subsection_name(name): name_css = 'input.new-subsection-name-input' save_css = 'input.new-subsection-name-save' diff --git a/cms/djangoapps/contentstore/features/video-editor.feature b/cms/djangoapps/contentstore/features/video-editor.feature new file mode 100644 index 0000000000..4c2a460042 --- /dev/null +++ b/cms/djangoapps/contentstore/features/video-editor.feature @@ -0,0 +1,13 @@ +Feature: Video Component Editor + As a course author, I want to be able to create video components. + + Scenario: User can view metadata + Given I have created a Video component + And I edit and select Settings + Then I see only the Video display name setting + + Scenario: User can modify display name + Given I have created a Video component + And I edit and select Settings + Then I can modify the display name + And my display name change is persisted on save diff --git a/cms/djangoapps/contentstore/features/video-editor.py b/cms/djangoapps/contentstore/features/video-editor.py new file mode 100644 index 0000000000..27423575c3 --- /dev/null +++ b/cms/djangoapps/contentstore/features/video-editor.py @@ -0,0 +1,9 @@ +# disable missing docstring +#pylint: disable=C0111 + +from lettuce import world, step + + +@step('I see only the video display name setting$') +def i_see_only_the_video_display_name(step): + world.verify_all_setting_entries([['Display Name', "default", True]]) diff --git a/cms/djangoapps/contentstore/features/video.feature b/cms/djangoapps/contentstore/features/video.feature new file mode 100644 index 0000000000..a4cf84d978 --- /dev/null +++ b/cms/djangoapps/contentstore/features/video.feature @@ -0,0 +1,6 @@ +Feature: Video Component + As a course author, I want to be able to view my created videos in Studio. + + Scenario: Autoplay is disabled in Studio + Given I have created a Video component + Then when I view the video it does not have autoplay enabled diff --git a/cms/djangoapps/contentstore/features/video.py b/cms/djangoapps/contentstore/features/video.py new file mode 100644 index 0000000000..f25b8d6d7e --- /dev/null +++ b/cms/djangoapps/contentstore/features/video.py @@ -0,0 +1,11 @@ +#pylint: disable=C0111 + +from lettuce import world, step + +############### ACTIONS #################### + + +@step('when I view the video it does not have autoplay enabled') +def does_not_autoplay(step): + assert world.css_find('.video')[0]['data-autoplay'] == 'False' + assert world.css_find('.video_control')[0].has_class('play') diff --git a/cms/djangoapps/contentstore/management/commands/check_course.py b/cms/djangoapps/contentstore/management/commands/check_course.py index 57965fe793..215bb8add8 100644 --- a/cms/djangoapps/contentstore/management/commands/check_course.py +++ b/cms/djangoapps/contentstore/management/commands/check_course.py @@ -59,7 +59,7 @@ class Command(BaseCommand): discussion_items = _get_discussion_items(course) # now query all discussion items via get_items() and compare with the tree-traversal - queried_discussion_items = store.get_items(['i4x', course.location.org, course.location.course, + queried_discussion_items = store.get_items(['i4x', course.location.org, course.location.course, 'discussion', None, None]) for item in queried_discussion_items: diff --git a/cms/djangoapps/contentstore/management/commands/clone.py b/cms/djangoapps/contentstore/management/commands/clone.py index abf04f3da3..0ca50acb50 100644 --- a/cms/djangoapps/contentstore/management/commands/clone.py +++ b/cms/djangoapps/contentstore/management/commands/clone.py @@ -5,7 +5,6 @@ from django.core.management.base import BaseCommand, CommandError from xmodule.modulestore.store_utilities import clone_course from xmodule.modulestore.django import modulestore from xmodule.contentstore.django import contentstore -from xmodule.modulestore import Location from xmodule.course_module import CourseDescriptor from auth.authz import _copy_course_group @@ -16,8 +15,7 @@ from auth.authz import _copy_course_group class Command(BaseCommand): - help = \ -'''Clone a MongoDB backed course to another location''' + help = 'Clone a MongoDB backed course to another location' def handle(self, *args, **options): if len(args) != 2: diff --git a/cms/djangoapps/contentstore/management/commands/delete_course.py b/cms/djangoapps/contentstore/management/commands/delete_course.py index fc92205030..5aafe9f8a6 100644 --- a/cms/djangoapps/contentstore/management/commands/delete_course.py +++ b/cms/djangoapps/contentstore/management/commands/delete_course.py @@ -5,7 +5,6 @@ from django.core.management.base import BaseCommand, CommandError from xmodule.modulestore.store_utilities import delete_course from xmodule.modulestore.django import modulestore from xmodule.contentstore.django import contentstore -from xmodule.modulestore import Location from xmodule.course_module import CourseDescriptor from .prompt import query_yes_no @@ -38,7 +37,7 @@ class Command(BaseCommand): if query_yes_no("Deleting course {0}. Confirm?".format(loc_str), default="no"): if query_yes_no("Are you sure. This action cannot be undone!", default="no"): loc = CourseDescriptor.id_to_location(loc_str) - if delete_course(ms, cs, loc, commit) == True: + if delete_course(ms, cs, loc, commit): print 'removing User permissions from course....' # in the django layer, we need to remove all the user permissions groups associated with this course if commit: diff --git a/cms/djangoapps/contentstore/management/commands/export.py b/cms/djangoapps/contentstore/management/commands/export.py index 11b043c2ab..eb7800d46c 100644 --- a/cms/djangoapps/contentstore/management/commands/export.py +++ b/cms/djangoapps/contentstore/management/commands/export.py @@ -7,7 +7,6 @@ from django.core.management.base import BaseCommand, CommandError from xmodule.modulestore.xml_exporter import export_to_xml from xmodule.modulestore.django import modulestore from xmodule.contentstore.django import contentstore -from xmodule.modulestore import Location from xmodule.course_module import CourseDescriptor @@ -15,8 +14,7 @@ unnamed_modules = 0 class Command(BaseCommand): - help = \ -'''Import the specified data directory into the default ModuleStore''' + help = 'Import the specified data directory into the default ModuleStore' def handle(self, *args, **options): if len(args) != 2: diff --git a/cms/djangoapps/contentstore/management/commands/import.py b/cms/djangoapps/contentstore/management/commands/import.py index 2a040f35b6..9b919daad0 100644 --- a/cms/djangoapps/contentstore/management/commands/import.py +++ b/cms/djangoapps/contentstore/management/commands/import.py @@ -12,8 +12,7 @@ unnamed_modules = 0 class Command(BaseCommand): - help = \ -'''Import the specified data directory into the default ModuleStore''' + help = 'Import the specified data directory into the default ModuleStore' def handle(self, *args, **options): if len(args) == 0: @@ -28,4 +27,4 @@ class Command(BaseCommand): data=data_dir, courses=course_dirs) import_from_xml(modulestore('direct'), data_dir, course_dirs, load_error_modules=False, - static_content_store=contentstore(), verbose=True) + static_content_store=contentstore(), verbose=True) diff --git a/cms/djangoapps/contentstore/management/commands/prompt.py b/cms/djangoapps/contentstore/management/commands/prompt.py index 40a39d0a11..44f981b5ac 100644 --- a/cms/djangoapps/contentstore/management/commands/prompt.py +++ b/cms/djangoapps/contentstore/management/commands/prompt.py @@ -11,8 +11,8 @@ def query_yes_no(question, default="yes"): The "answer" return value is one of "yes" or "no". """ - valid = {"yes":True, "y":True, "ye":True, - "no":False, "n":False} + valid = {"yes": True, "y": True, "ye": True, + "no": False, "n": False} if default is None: prompt = " [y/n] " elif default == "yes": @@ -30,5 +30,4 @@ def query_yes_no(question, default="yes"): elif choice in valid: return valid[choice] else: - sys.stdout.write("Please respond with 'yes' or 'no' "\ - "(or 'y' or 'n').\n") + sys.stdout.write("Please respond with 'yes' or 'no' (or 'y' or 'n').\n") diff --git a/cms/djangoapps/contentstore/management/commands/update_templates.py b/cms/djangoapps/contentstore/management/commands/update_templates.py index b30d30480a..36348314b9 100644 --- a/cms/djangoapps/contentstore/management/commands/update_templates.py +++ b/cms/djangoapps/contentstore/management/commands/update_templates.py @@ -1,9 +1,10 @@ from xmodule.templates import update_templates +from xmodule.modulestore.django import modulestore from django.core.management.base import BaseCommand + class Command(BaseCommand): - help = \ -'''Imports and updates the Studio component templates from the code pack and put in the DB''' + help = 'Imports and updates the Studio component templates from the code pack and put in the DB' def handle(self, *args, **options): - update_templates() \ No newline at end of file + update_templates(modulestore('direct')) diff --git a/cms/djangoapps/contentstore/management/commands/xlint.py b/cms/djangoapps/contentstore/management/commands/xlint.py index 6bc254a1ff..21c8e7d1f8 100644 --- a/cms/djangoapps/contentstore/management/commands/xlint.py +++ b/cms/djangoapps/contentstore/management/commands/xlint.py @@ -1,7 +1,5 @@ from django.core.management.base import BaseCommand, CommandError from xmodule.modulestore.xml_importer import perform_xlint -from xmodule.modulestore.django import modulestore -from xmodule.contentstore.django import contentstore unnamed_modules = 0 @@ -9,10 +7,11 @@ unnamed_modules = 0 class Command(BaseCommand): help = \ - ''' - Verify the structure of courseware as to it's suitability for import - To run test: rake cms:xlint DATA_DIR=../data [COURSE_DIR=content-edx-101 (optional parameter)] - ''' + ''' + Verify the structure of courseware as to it's suitability for import + To run test: rake cms:xlint DATA_DIR=../data [COURSE_DIR=content-edx-101 (optional parameter)] + ''' + def handle(self, *args, **options): if len(args) == 0: raise CommandError("import requires at least one argument: [...]") diff --git a/cms/djangoapps/contentstore/module_info_model.py b/cms/djangoapps/contentstore/module_info_model.py index 8ea6add88d..f7d1bbd8fe 100644 --- a/cms/djangoapps/contentstore/module_info_model.py +++ b/cms/djangoapps/contentstore/module_info_model.py @@ -1,7 +1,6 @@ from static_replace import replace_static_urls from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore import Location -from django.http import Http404 def get_module_info(store, location, parent_location=None, rewrite_static_links=False): @@ -76,11 +75,7 @@ def set_module_info(store, location, post_data): # IMPORTANT NOTE: if the client passed pack 'null' (None) for a piece of metadata that means 'remove it' for metadata_key, value in posted_metadata.items(): - # let's strip out any metadata fields from the postback which have been identified as system metadata - # and therefore should not be user-editable, so we should accept them back from the client - if metadata_key in module.system_metadata_fields: - del posted_metadata[metadata_key] - elif posted_metadata[metadata_key] is None: + if posted_metadata[metadata_key] is None: # remove both from passed in collection as well as the collection read in from the modulestore if metadata_key in module._model_data: del module._model_data[metadata_key] diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 451ab96ca6..0b4535bb70 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -6,16 +6,17 @@ from django.conf import settings from django.core.urlresolvers import reverse from path import path from tempdir import mkdtemp_clean -from datetime import timedelta -import json from fs.osfs import OSFS import copy from json import loads +from datetime import timedelta from django.contrib.auth.models import User +from django.dispatch import Signal from contentstore.utils import get_modulestore +from contentstore.tests.utils import parse_json -from .utils import ModuleStoreTestCase, parse_json +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from xmodule.modulestore import Location @@ -33,19 +34,25 @@ from xmodule.course_module import CourseDescriptor from xmodule.seq_module import SequenceDescriptor from xmodule.modulestore.exceptions import ItemNotFoundError +from contentstore.views.component import ADVANCED_COMPONENT_TYPES + +from django_comment_common.utils import are_permissions_roles_seeded + TEST_DATA_MODULESTORE = copy.deepcopy(settings.MODULESTORE) TEST_DATA_MODULESTORE['default']['OPTIONS']['fs_root'] = path('common/test/data') TEST_DATA_MODULESTORE['direct']['OPTIONS']['fs_root'] = path('common/test/data') + class MongoCollectionFindWrapper(object): def __init__(self, original): self.original = original self.counter = 0 def find(self, query, *args, **kwargs): - self.counter = self.counter+1 + self.counter = self.counter + 1 return self.original(query, *args, **kwargs) + @override_settings(MODULESTORE=TEST_DATA_MODULESTORE) class ContentStoreToyCourseTest(ModuleStoreTestCase): """ @@ -70,8 +77,33 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.client = Client() self.client.login(username=uname, password=password) + def test_advanced_components_in_edit_unit(self): + store = modulestore('direct') + import_from_xml(store, 'common/test/data/', ['simple']) + + course = store.get_item(Location(['i4x', 'edX', 'simple', + 'course', '2012_Fall', None]), depth=None) + + course.advanced_modules = ADVANCED_COMPONENT_TYPES + + store.update_metadata(course.location, own_metadata(course)) + + # just pick one vertical + descriptor = store.get_items(Location('i4x', 'edX', 'simple', 'vertical', None, None))[0] + + resp = self.client.get(reverse('edit_unit', kwargs={'location': descriptor.location.url()})) + self.assertEqual(resp.status_code, 200) + + # 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.assertIn('Video Alpha', resp.content) + self.assertIn('Word cloud', resp.content) + self.assertIn('Annotation', resp.content) + self.assertIn('Open Ended Response', resp.content) + self.assertIn('Peer Grading Interface', resp.content) + def check_edit_unit(self, test_course_name): - import_from_xml(modulestore(), 'common/test/data/', [test_course_name]) + import_from_xml(modulestore('direct'), 'common/test/data/', [test_course_name]) for descriptor in modulestore().get_items(Location(None, None, 'vertical', None, None)): print "Checking ", descriptor.location.url() @@ -92,13 +124,40 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): return cnt + def test_get_items(self): + ''' + This verifies a bug we had where the None setting in get_items() meant 'wildcard' + Unfortunately, None = published for the revision field, so get_items() would return + both draft and non-draft copies. + ''' + store = modulestore('direct') + draft_store = modulestore('draft') + import_from_xml(store, 'common/test/data/', ['simple']) + + html_module = draft_store.get_item(['i4x', 'edX', 'simple', 'html', 'test_html', None]) + + draft_store.clone_item(html_module.location, html_module.location) + + # now query get_items() to get this location with revision=None, this should just + # return back a single item (not 2) + + items = store.get_items(['i4x', 'edX', 'simple', 'html', 'test_html', None]) + self.assertEqual(len(items), 1) + self.assertFalse(getattr(items[0], 'is_draft', False)) + + # now refetch from the draft store. Note that even though we pass + # None in the revision field, the draft store will replace that with 'draft' + items = draft_store.get_items(['i4x', 'edX', 'simple', 'html', 'test_html', None]) + self.assertEqual(len(items), 1) + self.assertTrue(getattr(items[0], 'is_draft', False)) + def test_draft_metadata(self): ''' This verifies a bug we had where inherited metadata was getting written to the module as 'own-metadata' when publishing. Also verifies the metadata inheritance is properly computed ''' - store = modulestore() + store = modulestore('direct') draft_store = modulestore('draft') import_from_xml(store, 'common/test/data/', ['simple']) @@ -156,39 +215,52 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.assertEqual(html_module.lms.graceperiod, new_graceperiod) def test_get_depth_with_drafts(self): - import_from_xml(modulestore(), 'common/test/data/', ['simple']) + import_from_xml(modulestore('direct'), 'common/test/data/', ['simple']) - course = modulestore('draft').get_item(Location(['i4x', 'edX', 'simple', - 'course', '2012_Fall', None]), depth=None) + course = modulestore('draft').get_item( + Location(['i4x', 'edX', 'simple', 'course', '2012_Fall', None]), + depth=None + ) # make sure no draft items have been returned num_drafts = self._get_draft_counts(course) self.assertEqual(num_drafts, 0) - problem = modulestore('draft').get_item(Location(['i4x', 'edX', 'simple', - 'problem', 'ps01-simple', None])) + problem = modulestore('draft').get_item( + Location(['i4x', 'edX', 'simple', 'problem', 'ps01-simple', None]) + ) # put into draft modulestore('draft').clone_item(problem.location, problem.location) # make sure we can query that item and verify that it is a draft - draft_problem = modulestore('draft').get_item(Location(['i4x', 'edX', 'simple', - 'problem', 'ps01-simple', None])) - self.assertTrue(getattr(draft_problem,'is_draft', False)) + draft_problem = modulestore('draft').get_item( + Location(['i4x', 'edX', 'simple', 'problem', 'ps01-simple', None]) + ) + self.assertTrue(getattr(draft_problem, 'is_draft', False)) #now requery with depth - course = modulestore('draft').get_item(Location(['i4x', 'edX', 'simple', - 'course', '2012_Fall', None]), depth=None) + course = modulestore('draft').get_item( + Location(['i4x', 'edX', 'simple', 'course', '2012_Fall', None]), + depth=None + ) # make sure just one draft item have been returned num_drafts = self._get_draft_counts(course) - self.assertEqual(num_drafts, 1) + self.assertEqual(num_drafts, 1) + def test_import_textbook_as_content_element(self): + module_store = modulestore('direct') + import_from_xml(module_store, 'common/test/data/', ['full']) + + course = module_store.get_item(Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None])) + + self.assertGreater(len(course.textbooks), 0) def test_static_tab_reordering(self): - import_from_xml(modulestore(), 'common/test/data/', ['full']) - module_store = modulestore('direct') + import_from_xml(module_store, 'common/test/data/', ['full']) + course = module_store.get_item(Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None])) # reverse the ordering @@ -210,49 +282,47 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.assertEqual(reverse_tabs, course_tabs) def test_import_polls(self): - import_from_xml(modulestore(), 'common/test/data/', ['full']) - module_store = modulestore('direct') - found = False + import_from_xml(module_store, 'common/test/data/', ['full']) - item = None items = module_store.get_items(['i4x', 'edX', 'full', 'poll_question', None, None]) found = len(items) > 0 self.assertTrue(found) # check that there's actually content in the 'question' field - self.assertGreater(len(items[0].question),0) + self.assertGreater(len(items[0].question), 0) def test_xlint_fails(self): err_cnt = perform_xlint('common/test/data', ['full']) self.assertGreater(err_cnt, 0) def test_delete(self): - import_from_xml(modulestore(), 'common/test/data/', ['full']) + direct_store = modulestore('direct') + import_from_xml(direct_store, 'common/test/data/', ['full']) - module_store = modulestore('direct') + sequential = direct_store.get_item(Location(['i4x', 'edX', 'full', 'sequential', 'Administrivia_and_Circuit_Elements', None])) - sequential = module_store.get_item(Location(['i4x', 'edX', 'full', 'sequential', 'Administrivia_and_Circuit_Elements', None])) + chapter = direct_store.get_item(Location(['i4x', 'edX', 'full', 'chapter', 'Week_1', None])) - chapter = module_store.get_item(Location(['i4x', 'edX', 'full', 'chapter','Week_1', None])) - - # make sure the parent no longer points to the child object which was deleted + # make sure the parent points to the child object which is to be deleted self.assertTrue(sequential.location.url() in chapter.children) - self.client.post(reverse('delete_item'), + self.client.post( + reverse('delete_item'), json.dumps({'id': sequential.location.url(), 'delete_children': 'true', 'delete_all_versions': 'true'}), - "application/json") + "application/json" + ) found = False try: - module_store.get_item(Location(['i4x', 'edX', 'full', 'sequential', 'Administrivia_and_Circuit_Elements', None])) + direct_store.get_item(Location(['i4x', 'edX', 'full', 'sequential', 'Administrivia_and_Circuit_Elements', None])) found = True except ItemNotFoundError: pass self.assertFalse(found) - chapter = module_store.get_item(Location(['i4x', 'edX', 'full', 'chapter','Week_1', None])) + chapter = direct_store.get_item(Location(['i4x', 'edX', 'full', 'chapter', 'Week_1', None])) # make sure the parent no longer points to the child object which was deleted self.assertFalse(sequential.location.url() in chapter.children) @@ -262,8 +332,9 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): This test case verifies that a course can use specialized override for about data, e.g. /about/Fall_2012/effort.html while there is a base definition in /about/effort.html ''' - import_from_xml(modulestore(), 'common/test/data/', ['full']) module_store = modulestore('direct') + import_from_xml(module_store, 'common/test/data/', ['full']) + effort = module_store.get_item(Location(['i4x', 'edX', 'full', 'about', 'effort', None])) self.assertEqual(effort.data, '6 hours') @@ -272,10 +343,8 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.assertEqual(effort.data, 'TBD') def test_remove_hide_progress_tab(self): - import_from_xml(modulestore(), 'common/test/data/', ['full']) - module_store = modulestore('direct') - content_store = contentstore() + import_from_xml(module_store, 'common/test/data/', ['full']) source_location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012') course = module_store.get_item(source_location) @@ -288,16 +357,16 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): 'org': 'MITx', 'number': '999', 'display_name': 'Robot Super Course', - } + } - import_from_xml(modulestore(), 'common/test/data/', ['full']) + module_store = modulestore('direct') + import_from_xml(module_store, 'common/test/data/', ['full']) resp = self.client.post(reverse('create_new_course'), course_data) self.assertEqual(resp.status_code, 200) data = parse_json(resp) self.assertEqual(data['id'], 'i4x://MITx/999/course/Robot_Super_Course') - module_store = modulestore('direct') content_store = contentstore() source_location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012') @@ -312,7 +381,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): clone_items = module_store.get_items(Location(['i4x', 'MITx', '999', 'vertical', None])) self.assertGreater(len(clone_items), 0) for descriptor in items: - new_loc = descriptor.location._replace(org='MITx', course='999') + new_loc = descriptor.location.replace(org='MITx', course='999') print "Checking {0} should now also be at {1}".format(descriptor.location.url(), new_loc.url()) resp = self.client.get(reverse('edit_unit', kwargs={'location': new_loc.url()})) self.assertEqual(resp.status_code, 200) @@ -322,9 +391,9 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.assertEqual(resp.status_code, 400) def test_delete_course(self): - import_from_xml(modulestore(), 'common/test/data/', ['full']) - module_store = modulestore('direct') + import_from_xml(module_store, 'common/test/data/', ['full']) + content_store = contentstore() location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012') @@ -335,29 +404,60 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.assertEqual(len(items), 0) def verify_content_existence(self, modulestore, root_dir, location, dirname, category_name, filename_suffix=''): - fs = OSFS(root_dir / 'test_export') - self.assertTrue(fs.exists(dirname)) + filesystem = OSFS(root_dir / 'test_export') + self.assertTrue(filesystem.exists(dirname)) query_loc = Location('i4x', location.org, location.course, category_name, None) items = modulestore.get_items(query_loc) for item in items: - fs = OSFS(root_dir / ('test_export/' + dirname)) - self.assertTrue(fs.exists(item.location.name + filename_suffix)) + filesystem = OSFS(root_dir / ('test_export/' + dirname)) + self.assertTrue(filesystem.exists(item.location.name + filename_suffix)) def test_export_course(self): module_store = modulestore('direct') + draft_store = modulestore('draft') content_store = contentstore() import_from_xml(module_store, 'common/test/data/', ['full']) location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012') + # get a vertical (and components in it) to put into 'draft' + vertical = module_store.get_item(Location(['i4x', 'edX', 'full', + 'vertical', 'vertical_66', None]), depth=1) + + draft_store.clone_item(vertical.location, vertical.location) + + # We had a bug where orphaned draft nodes caused export to fail. This is here to cover that case. + draft_store.clone_item(vertical.location, Location(['i4x', 'edX', 'full', + 'vertical', 'no_references', 'draft'])) + + for child in vertical.get_children(): + draft_store.clone_item(child.location, child.location) + root_dir = path(mkdtemp_clean()) + # now create a private vertical + private_vertical = draft_store.clone_item(vertical.location, + Location(['i4x', 'edX', 'full', 'vertical', 'a_private_vertical', None])) + + # add private to list of children + sequential = module_store.get_item(Location(['i4x', 'edX', 'full', + 'sequential', 'Administrivia_and_Circuit_Elements', None])) + private_location_no_draft = private_vertical.location.replace(revision=None) + module_store.update_children(sequential.location, sequential.children + + [private_location_no_draft.url()]) + + # read back the sequential, to make sure we have a pointer to + sequential = module_store.get_item(Location(['i4x', 'edX', 'full', + 'sequential', 'Administrivia_and_Circuit_Elements', None])) + + self.assertIn(private_location_no_draft.url(), sequential.children) + print 'Exporting to tempdir = {0}'.format(root_dir) # export out to a tempdir - export_to_xml(module_store, content_store, location, root_dir, 'test_export') + export_to_xml(module_store, content_store, location, root_dir, 'test_export', draft_modulestore=draft_store) # check for static tabs self.verify_content_existence(module_store, root_dir, location, 'tabs', 'static_tab', '.html') @@ -369,20 +469,20 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.verify_content_existence(module_store, root_dir, location, 'custom_tags', 'custom_tag_template') # check for graiding_policy.json - fs = OSFS(root_dir / 'test_export/policies/6.002_Spring_2012') - self.assertTrue(fs.exists('grading_policy.json')) + filesystem = OSFS(root_dir / 'test_export/policies/6.002_Spring_2012') + self.assertTrue(filesystem.exists('grading_policy.json')) course = module_store.get_item(location) # compare what's on disk compared to what we have in our course - with fs.open('grading_policy.json', 'r') as grading_policy: + with filesystem.open('grading_policy.json', 'r') as grading_policy: on_disk = loads(grading_policy.read()) self.assertEqual(on_disk, course.grading_policy) #check for policy.json - self.assertTrue(fs.exists('policy.json')) + self.assertTrue(filesystem.exists('policy.json')) # compare what's on disk to what we have in the course module - with fs.open('policy.json', 'r') as course_policy: + with filesystem.open('policy.json', 'r') as course_policy: on_disk = loads(course_policy.read()) self.assertIn('course/6.002_Spring_2012', on_disk) self.assertEqual(on_disk['course/6.002_Spring_2012'], own_metadata(course)) @@ -391,20 +491,47 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): delete_course(module_store, content_store, location) # reimport - import_from_xml(module_store, root_dir, ['test_export']) + import_from_xml(module_store, root_dir, ['test_export'], draft_store=draft_store) items = module_store.get_items(Location(['i4x', 'edX', 'full', 'vertical', None])) self.assertGreater(len(items), 0) for descriptor in items: - print "Checking {0}....".format(descriptor.location.url()) - resp = self.client.get(reverse('edit_unit', kwargs={'location': descriptor.location.url()})) - self.assertEqual(resp.status_code, 200) + # don't try to look at private verticals. Right now we're running + # the service in non-draft aware + if getattr(descriptor, 'is_draft', False): + print "Checking {0}....".format(descriptor.location.url()) + resp = self.client.get(reverse('edit_unit', kwargs={'location': descriptor.location.url()})) + self.assertEqual(resp.status_code, 200) + + # verify that we have the content in the draft store as well + vertical = draft_store.get_item(Location(['i4x', 'edX', 'full', + 'vertical', 'vertical_66', None]), depth=1) + + self.assertTrue(getattr(vertical, 'is_draft', False)) + for child in vertical.get_children(): + self.assertTrue(getattr(child, 'is_draft', False)) + + # make sure that we don't have a sequential that is in draft mode + sequential = draft_store.get_item(Location(['i4x', 'edX', 'full', + 'sequential', 'Administrivia_and_Circuit_Elements', None])) + + self.assertFalse(getattr(sequential, 'is_draft', False)) + + # verify that we have the private vertical + test_private_vertical = draft_store.get_item(Location(['i4x', 'edX', 'full', + 'vertical', 'vertical_66', None])) + + self.assertTrue(getattr(test_private_vertical, 'is_draft', False)) + + # make sure the textbook survived the export/import + course = module_store.get_item(Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None])) + + self.assertGreater(len(course.textbooks), 0) shutil.rmtree(root_dir) def test_course_handouts_rewrites(self): module_store = modulestore('direct') - content_store = contentstore() # import a test course import_from_xml(module_store, 'common/test/data/', ['full']) @@ -422,8 +549,9 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.assertContains(resp, '/c4x/edX/full/asset/handouts_schematic_tutorial.pdf') def test_prefetch_children(self): - import_from_xml(modulestore(), 'common/test/data/', ['full']) module_store = modulestore('direct') + import_from_xml(module_store, 'common/test/data/', ['full']) + location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012') wrapper = MongoCollectionFindWrapper(module_store.collection.find) @@ -437,11 +565,11 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): # make sure we pre-fetched a known sequential which should be at depth=2 self.assertTrue(Location(['i4x', 'edX', 'full', 'sequential', - 'Administrivia_and_Circuit_Elements', None]) in course.system.module_data) + 'Administrivia_and_Circuit_Elements', None]) in course.system.module_data) # make sure we don't have a specific vertical which should be at depth=3 - self.assertFalse(Location(['i4x', 'edX', 'full', 'vertical', 'vertical_58', - None]) in course.system.module_data) + self.assertFalse(Location(['i4x', 'edX', 'full', 'vertical', 'vertical_58', None]) + in course.system.module_data) def test_export_course_with_unknown_metadata(self): module_store = modulestore('direct') @@ -463,14 +591,8 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): print 'Exporting to tempdir = {0}'.format(root_dir) # export out to a tempdir - exported = False - try: - export_to_xml(module_store, content_store, location, root_dir, 'test_export') - exported = True - except Exception: - pass + export_to_xml(module_store, content_store, location, root_dir, 'test_export') - self.assertTrue(exported) class ContentStoreTest(ModuleStoreTestCase): """ @@ -506,7 +628,7 @@ class ContentStoreTest(ModuleStoreTestCase): 'org': 'MITx', 'number': '999', 'display_name': 'Robot Super Course', - } + } def test_create_course(self): """Test new course creation - happy path""" @@ -515,6 +637,14 @@ class ContentStoreTest(ModuleStoreTestCase): data = parse_json(resp) self.assertEqual(data['id'], 'i4x://MITx/999/course/Robot_Super_Course') + def test_create_course_check_forum_seeding(self): + """Test new course creation and verify forum seeding """ + resp = self.client.post(reverse('create_new_course'), self.course_data) + self.assertEqual(resp.status_code, 200) + data = parse_json(resp) + self.assertEqual(data['id'], 'i4x://MITx/999/course/Robot_Super_Course') + self.assertTrue(are_permissions_roles_seeded('MITx/999/Robot_Super_Course')) + def test_create_course_duplicate_course(self): """Test new course creation - error path""" resp = self.client.post(reverse('create_new_course'), self.course_data) @@ -533,7 +663,7 @@ class ContentStoreTest(ModuleStoreTestCase): self.assertEqual(resp.status_code, 200) self.assertEqual(data['ErrMsg'], - 'There is already a course defined with the same organization and course number.') + 'There is already a course defined with the same organization and course number.') def test_create_course_with_bad_organization(self): """Test new course creation - error path for bad organization name""" @@ -543,16 +673,18 @@ class ContentStoreTest(ModuleStoreTestCase): self.assertEqual(resp.status_code, 200) self.assertEqual(data['ErrMsg'], - "Unable to create course 'Robot Super Course'.\n\nInvalid characters in 'University of California, Berkeley'.") + "Unable to create course 'Robot Super Course'.\n\nInvalid characters in 'University of California, Berkeley'.") def test_course_index_view_with_no_courses(self): """Test viewing the index page with no courses""" # Create a course so there is something to view resp = self.client.get(reverse('index')) - self.assertContains(resp, - '

My Courses

', + self.assertContains( + resp, + '

My Courses

', status_code=200, - html=True) + html=True + ) def test_course_factory(self): """Test that the course factory works correctly.""" @@ -569,26 +701,30 @@ class ContentStoreTest(ModuleStoreTestCase): """Test viewing the index page with an existing course""" CourseFactory.create(display_name='Robot Super Educational Course') resp = self.client.get(reverse('index')) - self.assertContains(resp, + self.assertContains( + resp, 'Robot Super Educational Course', status_code=200, - html=True) + html=True + ) def test_course_overview_view_with_course(self): """Test viewing the course overview page with an existing course""" CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course') data = { - 'org': 'MITx', - 'course': '999', - 'name': Location.clean('Robot Super Course'), - } + 'org': 'MITx', + 'course': '999', + 'name': Location.clean('Robot Super Course'), + } resp = self.client.get(reverse('course_index', kwargs=data)) - self.assertContains(resp, + self.assertContains( + resp, '
', status_code=200, - html=True) + html=True + ) def test_clone_item(self): """Test cloning an item. E.g. creating a new section""" @@ -598,14 +734,16 @@ class ContentStoreTest(ModuleStoreTestCase): 'parent_location': 'i4x://MITx/999/course/Robot_Super_Course', 'template': 'i4x://edx/templates/chapter/Empty', 'display_name': 'Section One', - } + } resp = self.client.post(reverse('clone_item'), section_data) self.assertEqual(resp.status_code, 200) data = parse_json(resp) - self.assertRegexpMatches(data['id'], - '^i4x:\/\/MITx\/999\/chapter\/([0-9]|[a-f]){32}$') + self.assertRegexpMatches( + data['id'], + r"^i4x://MITx/999/chapter/([0-9]|[a-f]){32}$" + ) def test_capa_module(self): """Test that a problem treats markdown specially.""" @@ -614,7 +752,7 @@ class ContentStoreTest(ModuleStoreTestCase): problem_data = { 'parent_location': 'i4x://MITx/999/course/Robot_Super_Course', 'template': 'i4x://edx/templates/problem/Blank_Common_Problem' - } + } resp = self.client.post(reverse('clone_item'), problem_data) @@ -633,7 +771,7 @@ class ContentStoreTest(ModuleStoreTestCase): Import and walk through some common URL endpoints. This just verifies non-500 and no other correct behavior, so it is not a deep test """ - import_from_xml(modulestore(), 'common/test/data/', ['simple']) + import_from_xml(modulestore('direct'), 'common/test/data/', ['simple']) loc = Location(['i4x', 'edX', 'simple', 'course', '2012_Fall', None]) resp = self.client.get(reverse('course_index', kwargs={'org': loc.org, @@ -700,44 +838,46 @@ class ContentStoreTest(ModuleStoreTestCase): self.assertEqual(200, resp.status_code) # go look at a subsection page - subsection_location = loc._replace(category='sequential', name='test_sequence') + subsection_location = loc.replace(category='sequential', name='test_sequence') resp = self.client.get(reverse('edit_subsection', kwargs={'location': subsection_location.url()})) self.assertEqual(200, resp.status_code) # go look at the Edit page - unit_location = loc._replace(category='vertical', name='test_vertical') + unit_location = loc.replace(category='vertical', name='test_vertical') resp = self.client.get(reverse('edit_unit', kwargs={'location': unit_location.url()})) self.assertEqual(200, resp.status_code) # delete a component - del_loc = loc._replace(category='html', name='test_html') + del_loc = loc.replace(category='html', name='test_html') resp = self.client.post(reverse('delete_item'), json.dumps({'id': del_loc.url()}), "application/json") self.assertEqual(200, resp.status_code) # delete a unit - del_loc = loc._replace(category='vertical', name='test_vertical') + del_loc = loc.replace(category='vertical', name='test_vertical') resp = self.client.post(reverse('delete_item'), json.dumps({'id': del_loc.url()}), "application/json") self.assertEqual(200, resp.status_code) # delete a unit - del_loc = loc._replace(category='sequential', name='test_sequence') + del_loc = loc.replace(category='sequential', name='test_sequence') resp = self.client.post(reverse('delete_item'), json.dumps({'id': del_loc.url()}), "application/json") self.assertEqual(200, resp.status_code) # delete a chapter - del_loc = loc._replace(category='chapter', name='chapter_2') + del_loc = loc.replace(category='chapter', name='chapter_2') resp = self.client.post(reverse('delete_item'), json.dumps({'id': del_loc.url()}), "application/json") self.assertEqual(200, resp.status_code) + def test_import_metadata_with_attempts_empty_string(self): - import_from_xml(modulestore(), 'common/test/data/', ['simple']) module_store = modulestore('direct') + import_from_xml(module_store, 'common/test/data/', ['simple']) + did_load_item = False try: module_store.get_item(Location(['i4x', 'edX', 'simple', 'problem', 'ps01-simple', None])) @@ -748,10 +888,49 @@ class ContentStoreTest(ModuleStoreTestCase): # make sure we found the item (e.g. it didn't error while loading) self.assertTrue(did_load_item) - def test_metadata_inheritance(self): - import_from_xml(modulestore(), 'common/test/data/', ['full']) - + def test_forum_id_generation(self): module_store = modulestore('direct') + import_from_xml(module_store, 'common/test/data/', ['full']) + + new_component_location = Location('i4x', 'edX', 'full', 'discussion', 'new_component') + source_template_location = Location('i4x', 'edx', 'templates', 'discussion', 'Discussion_Tag') + + # crate a new module and add it as a child to a vertical + module_store.clone_item(source_template_location, new_component_location) + + new_discussion_item = module_store.get_item(new_component_location) + + self.assertNotEquals(new_discussion_item.discussion_id, '$$GUID$$') + + def test_update_modulestore_signal_did_fire(self): + module_store = modulestore('direct') + import_from_xml(module_store, 'common/test/data/', ['full']) + + try: + module_store.modulestore_update_signal = Signal(providing_args=['modulestore', 'course_id', 'location']) + + self.got_signal = False + + def _signal_hander(modulestore=None, course_id=None, location=None, **kwargs): + self.got_signal = True + + module_store.modulestore_update_signal.connect(_signal_hander) + + new_component_location = Location('i4x', 'edX', 'full', 'html', 'new_component') + source_template_location = Location('i4x', 'edx', 'templates', 'html', 'Blank_HTML_Page') + + # crate a new module + module_store.clone_item(source_template_location, new_component_location) + + finally: + module_store.modulestore_update_signal = None + + self.assertTrue(self.got_signal) + + def test_metadata_inheritance(self): + module_store = modulestore('direct') + import_from_xml(module_store, 'common/test/data/', ['full']) + course = module_store.get_item(Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None])) verticals = module_store.get_items(['i4x', 'edX', 'full', 'vertical', None, None]) @@ -807,7 +986,7 @@ class TemplateTestCase(ModuleStoreTestCase): self.assertIsNotNone(verify_create) # now run cleanup - update_templates() + update_templates(modulestore('direct')) # now try to find dangling template, it should not be in DB any longer asserted = False diff --git a/cms/djangoapps/contentstore/tests/test_core_caching.py b/cms/djangoapps/contentstore/tests/test_core_caching.py index 676627a045..34ed24699d 100644 --- a/cms/djangoapps/contentstore/tests/test_core_caching.py +++ b/cms/djangoapps/contentstore/tests/test_core_caching.py @@ -23,14 +23,14 @@ class CachingTestCase(TestCase): def test_put_and_get(self): set_cached_content(self.mockAsset) self.assertEqual(self.mockAsset.content, get_cached_content(self.unicodeLocation).content, - 'should be stored in cache with unicodeLocation') + 'should be stored in cache with unicodeLocation') self.assertEqual(self.mockAsset.content, get_cached_content(self.nonUnicodeLocation).content, - 'should be stored in cache with nonUnicodeLocation') + 'should be stored in cache with nonUnicodeLocation') def test_delete(self): set_cached_content(self.mockAsset) del_cached_content(self.nonUnicodeLocation) self.assertEqual(None, get_cached_content(self.unicodeLocation), - 'should not be stored in cache with unicodeLocation') + 'should not be stored in cache with unicodeLocation') self.assertEqual(None, get_cached_content(self.nonUnicodeLocation), - 'should not be stored in cache with nonUnicodeLocation') + 'should not be stored in cache with nonUnicodeLocation') diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py index fe90ad18aa..2a4ff46038 100644 --- a/cms/djangoapps/contentstore/tests/test_course_settings.py +++ b/cms/djangoapps/contentstore/tests/test_course_settings.py @@ -8,19 +8,18 @@ from django.core.urlresolvers import reverse from django.utils.timezone import UTC from xmodule.modulestore import Location -from models.settings.course_details import (CourseDetails, - CourseSettingsEncoder) +from models.settings.course_details import (CourseDetails, CourseSettingsEncoder) from models.settings.course_grading import CourseGradingModel from contentstore.utils import get_modulestore -from .utils import ModuleStoreTestCase +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory from models.settings.course_metadata import CourseMetadata from xmodule.modulestore.xml_importer import import_from_xml -from xmodule.modulestore.django import modulestore from xmodule.fields import Date + class CourseTestCase(ModuleStoreTestCase): def setUp(self): """ @@ -47,12 +46,8 @@ class CourseTestCase(ModuleStoreTestCase): self.client = Client() self.client.login(username=uname, password=password) - t = 'i4x://edx/templates/course/Empty' - o = 'MITx' - n = '999' - dn = 'Robot Super Course' - self.course_location = Location('i4x', o, n, 'course', 'Robot_Super_Course') - CourseFactory.create(template=t, org=o, number=n, display_name=dn) + course = CourseFactory.create(template='i4x://edx/templates/course/Empty', org='MITx', number='999', display_name='Robot Super Course') + self.course_location = course.location class CourseDetailsTestCase(CourseTestCase): @@ -86,17 +81,25 @@ class CourseDetailsTestCase(CourseTestCase): jsondetails = CourseDetails.fetch(self.course_location) jsondetails.syllabus = "bar" # encode - decode to convert date fields and other data which changes form - self.assertEqual(CourseDetails.update_from_json(jsondetails.__dict__).syllabus, - jsondetails.syllabus, "After set syllabus") + self.assertEqual( + CourseDetails.update_from_json(jsondetails.__dict__).syllabus, + jsondetails.syllabus, "After set syllabus" + ) jsondetails.overview = "Overview" - self.assertEqual(CourseDetails.update_from_json(jsondetails.__dict__).overview, - jsondetails.overview, "After set overview") + self.assertEqual( + CourseDetails.update_from_json(jsondetails.__dict__).overview, + jsondetails.overview, "After set overview" + ) jsondetails.intro_video = "intro_video" - self.assertEqual(CourseDetails.update_from_json(jsondetails.__dict__).intro_video, - jsondetails.intro_video, "After set intro_video") + self.assertEqual( + CourseDetails.update_from_json(jsondetails.__dict__).intro_video, + jsondetails.intro_video, "After set intro_video" + ) jsondetails.effort = "effort" - self.assertEqual(CourseDetails.update_from_json(jsondetails.__dict__).effort, - jsondetails.effort, "After set effort") + self.assertEqual( + CourseDetails.update_from_json(jsondetails.__dict__).effort, + jsondetails.effort, "After set effort" + ) class CourseDetailsViewTest(CourseTestCase): @@ -150,9 +153,7 @@ class CourseDetailsViewTest(CourseTestCase): @staticmethod def struct_to_datetime(struct_time): - return datetime.datetime(struct_time.tm_year, struct_time.tm_mon, - struct_time.tm_mday, struct_time.tm_hour, - struct_time.tm_min, struct_time.tm_sec, tzinfo=UTC()) + return datetime.datetime(*struct_time[:6], tzinfo=UTC()) def compare_date_fields(self, details, encoded, context, field): if details[field] is not None: @@ -249,14 +250,14 @@ class CourseGradingTest(CourseTestCase): altered_grader = CourseGradingModel.update_grader_from_json(test_grader.course_location, test_grader.graders[1]) self.assertDictEqual(test_grader.graders[1], altered_grader, "drop_count[1] + 2") + class CourseMetadataEditingTest(CourseTestCase): def setUp(self): CourseTestCase.setUp(self) # add in the full class too - import_from_xml(modulestore(), 'common/test/data/', ['full']) + import_from_xml(get_modulestore(self.course_location), 'common/test/data/', ['full']) self.fullcourse_location = Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None]) - def test_fetch_initial_fields(self): test_model = CourseMetadata.fetch(self.course_location) self.assertIn('display_name', test_model, 'Missing editable metadata field') @@ -271,18 +272,20 @@ class CourseMetadataEditingTest(CourseTestCase): self.assertIn('xqa_key', test_model, 'xqa_key field ') def test_update_from_json(self): - test_model = CourseMetadata.update_from_json(self.course_location, - { "advertised_start" : "start A", - "testcenter_info" : { "c" : "test" }, - "days_early_for_beta" : 2}) + test_model = CourseMetadata.update_from_json(self.course_location, { + "advertised_start": "start A", + "testcenter_info": {"c": "test"}, + "days_early_for_beta": 2 + }) self.update_check(test_model) # try fresh fetch to ensure persistence test_model = CourseMetadata.fetch(self.course_location) self.update_check(test_model) # now change some of the existing metadata - test_model = CourseMetadata.update_from_json(self.course_location, - { "advertised_start" : "start B", - "display_name" : "jolly roger"}) + test_model = CourseMetadata.update_from_json(self.course_location, { + "advertised_start": "start B", + "display_name": "jolly roger"} + ) self.assertIn('display_name', test_model, 'Missing editable metadata field') self.assertEqual(test_model['display_name'], 'jolly roger', "not expected value") self.assertIn('advertised_start', test_model, 'Missing revised advertised_start metadata field') @@ -294,13 +297,12 @@ class CourseMetadataEditingTest(CourseTestCase): self.assertIn('advertised_start', test_model, 'Missing new advertised_start metadata field') self.assertEqual(test_model['advertised_start'], 'start A', "advertised_start not expected value") self.assertIn('testcenter_info', test_model, 'Missing testcenter_info metadata field') - self.assertDictEqual(test_model['testcenter_info'], { "c" : "test" }, "testcenter_info not expected value") + self.assertDictEqual(test_model['testcenter_info'], {"c": "test"}, "testcenter_info not expected value") self.assertIn('days_early_for_beta', test_model, 'Missing days_early_for_beta metadata field') self.assertEqual(test_model['days_early_for_beta'], 2, "days_early_for_beta not expected value") - def test_delete_key(self): - test_model = CourseMetadata.delete_key(self.fullcourse_location, { 'deleteKeys' : ['doesnt_exist', 'showanswer', 'xqa_key']}) + test_model = CourseMetadata.delete_key(self.fullcourse_location, {'deleteKeys': ['doesnt_exist', 'showanswer', 'xqa_key']}) # ensure no harm self.assertNotIn('graceperiod', test_model, 'blacklisted field leaked in') self.assertIn('display_name', test_model, 'full missing editable metadata field') diff --git a/cms/djangoapps/contentstore/tests/test_course_updates.py b/cms/djangoapps/contentstore/tests/test_course_updates.py index 80d4f0bbc2..ae14555b32 100644 --- a/cms/djangoapps/contentstore/tests/test_course_updates.py +++ b/cms/djangoapps/contentstore/tests/test_course_updates.py @@ -10,9 +10,9 @@ class CourseUpdateTest(CourseTestCase): '''Go through each interface and ensure it works.''' # first get the update to force the creation url = reverse('course_info', - kwargs={'org': self.course_location.org, - 'course': self.course_location.course, - 'name': self.course_location.name}) + kwargs={'org': self.course_location.org, + 'course': self.course_location.course, + 'name': self.course_location.name}) self.client.get(url) init_content = ' \ No newline at end of file diff --git a/common/test/data/word_cloud/chapter/Staff.xml b/common/test/data/word_cloud/chapter/Staff.xml new file mode 100644 index 0000000000..e1d5216f6d --- /dev/null +++ b/common/test/data/word_cloud/chapter/Staff.xml @@ -0,0 +1,3 @@ + + + diff --git a/common/test/data/word_cloud/course.xml b/common/test/data/word_cloud/course.xml new file mode 100644 index 0000000000..1b97a5a714 --- /dev/null +++ b/common/test/data/word_cloud/course.xml @@ -0,0 +1,2 @@ + + diff --git a/common/test/data/word_cloud/course/2013_Spring.xml b/common/test/data/word_cloud/course/2013_Spring.xml new file mode 100644 index 0000000000..cb6e7c1217 --- /dev/null +++ b/common/test/data/word_cloud/course/2013_Spring.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/common/test/data/word_cloud/creating_course.xml b/common/test/data/word_cloud/creating_course.xml new file mode 100644 index 0000000000..4c90f1c2ec --- /dev/null +++ b/common/test/data/word_cloud/creating_course.xml @@ -0,0 +1,8 @@ + diff --git a/common/test/data/word_cloud/info/2013_Spring/handouts.html b/common/test/data/word_cloud/info/2013_Spring/handouts.html new file mode 100644 index 0000000000..35f2c89474 --- /dev/null +++ b/common/test/data/word_cloud/info/2013_Spring/handouts.html @@ -0,0 +1,3 @@ +
    +
  1. A list of course handouts, or an empty file if there are none.
  2. +
diff --git a/common/test/data/word_cloud/info/2013_Spring/updates.html b/common/test/data/word_cloud/info/2013_Spring/updates.html new file mode 100644 index 0000000000..9744c1699d --- /dev/null +++ b/common/test/data/word_cloud/info/2013_Spring/updates.html @@ -0,0 +1,10 @@ + +
    + +
  1. December 9

    +
    +

    Announcement text

    +
    +
  2. + +
diff --git a/common/test/data/word_cloud/policies/2013_Spring/policy.json b/common/test/data/word_cloud/policies/2013_Spring/policy.json new file mode 100644 index 0000000000..e2a204815c --- /dev/null +++ b/common/test/data/word_cloud/policies/2013_Spring/policy.json @@ -0,0 +1,8 @@ +{ + "course/2013_Spring": { + "start": "2099-01-01T00:00", + "advertised_start" : "Spring 2013", + "display_name": "Justice" + } + +} diff --git a/common/test/data/word_cloud/roots/2013_Spring.xml b/common/test/data/word_cloud/roots/2013_Spring.xml new file mode 100644 index 0000000000..1b97a5a714 --- /dev/null +++ b/common/test/data/word_cloud/roots/2013_Spring.xml @@ -0,0 +1,2 @@ + + diff --git a/common/test/data/word_cloud/sequential/Problem_Demos.xml b/common/test/data/word_cloud/sequential/Problem_Demos.xml new file mode 100644 index 0000000000..21568128a4 --- /dev/null +++ b/common/test/data/word_cloud/sequential/Problem_Demos.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/common/test/data/word_cloud/static/README b/common/test/data/word_cloud/static/README new file mode 100644 index 0000000000..e22f378b5e --- /dev/null +++ b/common/test/data/word_cloud/static/README @@ -0,0 +1,5 @@ +Images, handouts, and other statically-served content should go ONLY +in this directory. + +Images for the front page should go in static/images. The frontpage +banner MUST be named course_image.jpg \ No newline at end of file diff --git a/common/test/data/word_cloud/word_cloud/cloud.xml b/common/test/data/word_cloud/word_cloud/cloud.xml new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/test/phantom-jasmine b/common/test/phantom-jasmine deleted file mode 160000 index a54d435b55..0000000000 --- a/common/test/phantom-jasmine +++ /dev/null @@ -1 +0,0 @@ -Subproject commit a54d435b5556650efbcdb0490e6c7928ac75238a diff --git a/conf/locale/babel.cfg b/conf/locale/babel.cfg new file mode 100644 index 0000000000..5b8333cf1e --- /dev/null +++ b/conf/locale/babel.cfg @@ -0,0 +1,19 @@ +# Extraction from Python source files +#[python: cms/**.py] +#[python: lms/**.py] +#[python: common/**.py] + +# Extraction from Javscript source files +#[javascript: cms/**.js] +#[javascript: lms/**.js] +#[javascript: common/static/js/capa/**.js] +#[javascript: common/static/js/course_groups/**.js] +# do not extract from common/static/js/vendor/** + +# Extraction from Mako templates +[mako: cms/templates/**.html] +input_encoding = utf-8 +[mako: lms/templates/**.html] +input_encoding = utf-8 +[mako: common/templates/**.html] +input_encoding = utf-8 diff --git a/conf/locale/config b/conf/locale/config new file mode 100644 index 0000000000..58f8da0513 --- /dev/null +++ b/conf/locale/config @@ -0,0 +1,4 @@ +{ + "locales" : ["en", "es"], + "dummy-locale" : "fr" +} diff --git a/conf/locale/en/LC_MESSAGES/messages.po b/conf/locale/en/LC_MESSAGES/messages.po new file mode 100644 index 0000000000..e5961753c5 --- /dev/null +++ b/conf/locale/en/LC_MESSAGES/messages.po @@ -0,0 +1,20 @@ +# edX translation file +# Copyright (C) 2013 edX +# This file is distributed under the GNU AFFERO GENERAL PUBLIC LICENSE. +# +msgid "" +msgstr "" +"Project-Id-Version: EdX Studio\n" +"Report-Msgid-Bugs-To: translation_team@edx.org\n" +"POT-Creation-Date: 2013-05-02 13:13-0400\n" +"PO-Revision-Date: 2013-05-02 13:27-0400\n" +"Last-Translator: \n" +"Language-Team: translation team \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Language: en\n" + +# empty +msgid "This is a key string." +msgstr "" diff --git a/create-dev-env.sh b/create-dev-env.sh deleted file mode 100755 index f0ebca3ff7..0000000000 --- a/create-dev-env.sh +++ /dev/null @@ -1,377 +0,0 @@ -#!/usr/bin/env bash -set -e - -# posix compliant sanity check -if [ -z $BASH ] || [ $BASH = "/bin/sh" ]; then - echo "Please use the bash interpreter to run this script" - exit 1 -fi - -trap "ouch" ERR - -ouch() { - printf '\E[31m' - - cat< >(tee $LOG) -exec 2>&1 - - -# Install basic system requirements - -mkdir -p $BASE -case `uname -s` in - [Ll]inux) - command -v lsb_release &>/dev/null || { - error "Please install lsb-release." - exit 1 - } - - distro=`lsb_release -cs` - case $distro in - maya|lisa|natty|oneiric|precise|quantal) - sudo apt-get install git - ;; - *) - error "Unsupported distribution - $distro" - exit 1 - ;; - esac - ;; - - Darwin) - if [[ ! -w /usr/local ]]; then - cat</dev/null || { - output "Installing brew" - /usr/bin/ruby <(curl -fsSkL raw.github.com/mxcl/homebrew/go) - } - command -v git &>/dev/null || { - output "Installing git" - brew install git - } - - ;; - *) - error "Unsupported platform" - exit 1 - ;; -esac - - -# Clone MITx repositories - -clone_repos - - -# Install system-level dependencies - -bash $BASE/mitx/install-system-req.sh - - -# Install Ruby RVM - -output "Installing rvm and ruby" - -if ! grep -q "export rvm_path=$RUBY_DIR" ~/.rvmrc; then - if [[ -f $HOME/.rvmrc ]]; then - output "Copying existing .rvmrc to .rvmrc.bak" - cp $HOME/.rvmrc $HOME/.rvmrc.bak - fi - output "Creating $HOME/.rvmrc so rvm uses $RUBY_DIR" - echo "export rvm_path=$RUBY_DIR" > $HOME/.rvmrc -fi - -curl -sL get.rvm.io | bash -s -- --version 1.15.7 -source $RUBY_DIR/scripts/rvm -LESS="-E" rvm install $RUBY_VER --with-readline - -output "Installing gem bundler" -gem install bundler - -output "Installing ruby packages" -# hack :( -cd $BASE/mitx || true -bundle install - - -# Install Python virtualenv - -output "Installing python virtualenv" - -case `uname -s` in - Darwin) - # Add brew's path - PATH=/usr/local/share/python:/usr/local/bin:$PATH - ;; -esac - -if [[ $systempkgs ]]; then - virtualenv --system-site-packages "$PYTHON_DIR" -else - # default behavior for virtualenv>1.7 is - # --no-site-packages - virtualenv "$PYTHON_DIR" -fi - -# activate mitx python virtualenv -source $PYTHON_DIR/bin/activate - -# compile numpy and scipy if requested - -NUMPY_VER="1.6.2" -SCIPY_VER="0.10.1" - -if [[ -n $compile ]]; then - output "Downloading numpy and scipy" - curl -sL -o numpy.tar.gz http://downloads.sourceforge.net/project/numpy/NumPy/${NUMPY_VER}/numpy-${NUMPY_VER}.tar.gz - curl -sL -o scipy.tar.gz http://downloads.sourceforge.net/project/scipy/scipy/${SCIPY_VER}/scipy-${SCIPY_VER}.tar.gz - tar xf numpy.tar.gz - tar xf scipy.tar.gz - rm -f numpy.tar.gz scipy.tar.gz - output "Compiling numpy" - cd "$BASE/numpy-${NUMPY_VER}" - python setup.py install - output "Compiling scipy" - cd "$BASE/scipy-${SCIPY_VER}" - python setup.py install - cd "$BASE" - rm -rf numpy-${NUMPY_VER} scipy-${SCIPY_VER} -fi - -case `uname -s` in - Darwin) - # on mac os x get the latest distribute and pip - curl http://python-distribute.org/distribute_setup.py | python - pip install -U pip - # need latest pytz before compiling numpy and scipy - pip install -U pytz - pip install numpy - # fixes problem with scipy on 10.8 - pip install -e git+https://github.com/scipy/scipy#egg=scipy-dev - ;; -esac - -output "Installing MITx pre-requirements" -pip install -r $BASE/mitx/pre-requirements.txt - -output "Installing MITx requirements" -# Need to be in the mitx dir to get the paths to local modules right -cd $BASE/mitx -pip install -r requirements.txt - -mkdir "$BASE/log" || true -mkdir "$BASE/db" || true - - -# Configure Git - -output "Fixing your git default settings" -git config --global push.default current - - -### DONE - -cat< - - $ rake django-admin[runserver,lms,dev,] - - If the Django development server starts properly you - should see: - - Development server is running at http://127.0.0.1:/ - Quit the server with CONTROL-C. - - Connect your browser to http://127.0.0.1: to - view the Django site. - - -END -exit 0 diff --git a/distribute-0.6.32.tar.gz b/distribute-0.6.32.tar.gz deleted file mode 100644 index 2438db60fa..0000000000 Binary files a/distribute-0.6.32.tar.gz and /dev/null differ diff --git a/distribute-0.6.34.tar.gz b/distribute-0.6.34.tar.gz deleted file mode 100644 index 4e91b3af62..0000000000 Binary files a/distribute-0.6.34.tar.gz and /dev/null differ diff --git a/doc/README b/doc/README index d40f5d988d..395fc07dc5 100644 --- a/doc/README +++ b/doc/README @@ -1,3 +1,3 @@ -This directory contains some high level documentation for the code. We should strive to keep it up-to-date, but don't take it as the absolute truth. +This directory contains some high level documentation for the code. -A good place to start is 'overview.md' +WARNING: much of this is out-of-date. It stil may be helpful, though. diff --git a/doc/development.md b/doc/development.md index 184767a139..c99e99f906 100644 --- a/doc/development.md +++ b/doc/development.md @@ -12,7 +12,6 @@ This will read the `Gemfile` and install all of the gems specified there. Run the following:: pip install -r requirements.txt - pip install -r test-requirements.txt ### Binaries @@ -32,6 +31,14 @@ Check out the course data directories that you want to work with into the rake resetdb +## Installing + +To create your development environment, run the shell script in the root of +the repo: + + scripts/create-dev-env.sh + + ## Starting development servers Both the LMS and Studio can be started using the following shortcut tasks @@ -52,81 +59,17 @@ or with additional options: *N.B.* You may have to escape the `[` characters, depending on your shell: `rake "lms[test,5000]"` -## Running tests - -### Python Tests - -This runs all the tests (long, uses collectstatic): - - rake test - -If if you aren't changing static files, can run `rake test` once, then run - - rake fasttest_lms - -or - - rake fasttest_cms - -xmodule can be tested independently, with this: - - rake test_common/lib/xmodule - -To run a single django test class: - - django-admin.py test --settings=lms.envs.test --pythonpath=. lms/djangoapps/courseware/tests/tests.py:TestViewAuth - -To run a single django test: - - django-admin.py test --settings=lms.envs.test --pythonpath=. lms/djangoapps/courseware/tests/tests.py:TestViewAuth.test_dark_launch - - -To run a single nose test file: - - nosetests common/lib/xmodule/xmodule/tests/test_stringify.py - -To run a single nose test: - - nosetests common/lib/xmodule/xmodule/tests/test_stringify.py:test_stringify - - -Very handy: if you uncomment the `--pdb` argument in `NOSE_ARGS` in `lms/envs/test.py`, it will drop you into pdb on error. This lets you go up and down the stack and see what the values of the variables are. Check out http://docs.python.org/library/pdb.html - - -### Javascript Tests - -These commands start a development server with jasmine testing enabled, and launch your default browser -pointing to those tests - - rake browse_jasmine_{lms,cms} - -To run the tests headless, you must install phantomjs (http://phantomjs.org/download.html). - - rake phantomjs_jasmine_{lms,cms} - -If the `phantomjs` binary is not on the path, set the `PHANTOMJS_PATH` environment variable to point to it - - PHANTOMJS_PATH=/path/to/phantomjs rake phantomjs_jasmine_{lms,cms} - - -## Getting More Information - -Run the following to see a list of all rake tasks available and their arguments +To get a full list of available rake tasks, use: rake -T -## Testing using queue servers - -When testing problems that use a queue server on AWS (e.g. sandbox-xqueue.edx.org), you'll need to run your server on your public IP, like so. - -`django-admin.py runserver --settings=lms.envs.dev --pythonpath=. 0.0.0.0:8000` - -When you connect to the LMS, you need to use the public ip. Use `ifconfig` to figure out the numnber, and connect e.g. to `http://18.3.4.5:8000/` +## Running Tests +See `testing.md` for instructions on running the test suite. ## Content development -If you change course content, while running the LMS in dev mode, it is unnecessary to restart to refresh the modulestore. +If you change course content, while running the LMS in dev mode, it is unnecessary to restart to refresh the modulestore. Instead, hit /migrate/modules to see a list of all modules loaded, and click on links (eg /migrate/reload/edx4edx) to reload a course. diff --git a/doc/overview.md b/doc/overview.md index f64d12920d..4d074dfaf3 100644 --- a/doc/overview.md +++ b/doc/overview.md @@ -1,4 +1,4 @@ -# Documentation for edX code (mitx repo) +# Documentation for edX code (edx-platform repo) This document explains the general structure of the edX platform, and defines some of the acronyms and terms you'll see flying around in the code. diff --git a/doc/public/course_data_formats/course_xml.rst b/doc/public/course_data_formats/course_xml.rst index 56d831d972..c17175bafa 100644 --- a/doc/public/course_data_formats/course_xml.rst +++ b/doc/public/course_data_formats/course_xml.rst @@ -359,6 +359,8 @@ Supported fields at the course level * `auto_cohort_groups`: `["group name 1", "group name 2", ...]` If `cohorted` and `auto_cohort` is true, automatically put each student into a random group from the `auto_cohort_groups` list, creating the group if needed. * - `pdf_textbooks` - have pdf-based textbooks on tabs in the courseware. See below for details on config. + * - `html_textbooks` + - have html-based textbooks on tabs in the courseware. See below for details on config. Available metadata @@ -385,7 +387,14 @@ Inherited When this content should be shown to students. Note that anyone with staff access to the course will always see everything. `showanswer` - When to show answer. For 'attempted', will show answer after first attempt. Values: never, attempted, answered, closed. Default: closed. Optional. + When to show answer. Values: never, attempted, answered, closed, finished, past_due, always. Default: closed. Optional. + - `never`: never show answer + - `attempted`: show answer after first attempt + - `answered` : this is slightly different from `attempted` -- resetting the problems makes "done" False, but leaves attempts unchanged. + - `closed` : show answer after problem is closed, ie due date is past, or maximum attempts exceeded. + - `finished` : show answer after problem closed, or is correctly answered. + - `past_due` : show answer after problem due date is past. + - `always` : always allow answer to be shown. `graded` Whether this section will count towards the students grade. "true" or "false". Defaults to "false". @@ -511,6 +520,7 @@ If you want to customize the courseware tabs displayed for your course, specify "name": "Exciting news" }, {"type": "textbooks"}, + {"type": "html_textbooks"}, {"type": "pdf_textbooks"} ] @@ -518,6 +528,7 @@ If you want to customize the courseware tabs displayed for your course, specify * The first two tabs must have types `"courseware"` and `"course_info"`, in that order, or the course will not load. * The `courseware` tab never has a name attribute -- it's always rendered as "Courseware" for consistency between courses. * The `textbooks` tab will actually generate one tab per textbook, using the textbook titles as names. +* The `html_textbooks` tab will actually generate one tab per html_textbook. The tab name is found in the html textbook definition. * The `pdf_textbooks` tab will actually generate one tab per pdf_textbook. The tab name is found in the pdf textbook definition. * For static tabs, the `url_slug` will be the url that points to the tab. It can not be one of the existing courseware url types (even if those aren't used in your course). The static content will come from `tabs/{course_url_name}/{url_slug}.html`, or `tabs/{url_slug}.html` if that doesn't exist. * An Instructor tab will be automatically added at the end for course staff users. @@ -538,6 +549,8 @@ If you want to customize the courseware tabs displayed for your course, specify - Parameters `name`, `link`. * - `textbooks` - No parameters--generates tab names from book titles. + * - `html_textbooks` + - No parameters--generates tab names from html book definition. (See discussion below for configuration.) * - `pdf_textbooks` - No parameters--generates tab names from pdf book definition. (See discussion below for configuration.) * - `progress` @@ -550,7 +563,7 @@ If you want to customize the courseware tabs displayed for your course, specify ********* Textbooks ********* -Support is currently provided for image-based and PDF-based textbooks. In addition to enabling the display of textbooks in tabs (see above), specific information about the location of textbook content must be configured. +Support is currently provided for image-based, HTML-based and PDF-based textbooks. In addition to enabling the display of textbooks in tabs (see above), specific information about the location of textbook content must be configured. Image-based Textbooks ===================== @@ -623,6 +636,62 @@ The course content can then link to page 25 using the `customtag` element: +HTML-based Textbooks +==================== + +Configuration +------------- + +HTML-based textbooks are configured at the course level in the policy file. The JSON markup consists of an array of maps, with each map corresponding to a separate textbook. There are two styles to presenting HTML-based material. The first way is as a single HTML on a tab, which requires only a tab title and a URL for configuration. A second way permits the display of multiple HTML files that should be displayed together on a single view. For this view, a side panel of links is available on the left, allowing selection of a particular HTML to view. + +.. code-block:: json + + "html_textbooks": [ + {"tab_title": "Textbook 1", + "url": "https://www.example.com/thiscourse/book1/book1.html" }, + {"tab_title": "Textbook 2", + "chapters": [ + { "title": "Chapter 1", "url": "https://www.example.com/thiscourse/book2/Chapter1.html" }, + { "title": "Chapter 2", "url": "https://www.example.com/thiscourse/book2/Chapter2.html" }, + { "title": "Chapter 3", "url": "https://www.example.com/thiscourse/book2/Chapter3.html" }, + { "title": "Chapter 4", "url": "https://www.example.com/thiscourse/book2/Chapter4.html" }, + { "title": "Chapter 5", "url": "https://www.example.com/thiscourse/book2/Chapter5.html" }, + { "title": "Chapter 6", "url": "https://www.example.com/thiscourse/book2/Chapter6.html" }, + { "title": "Chapter 7", "url": "https://www.example.com/thiscourse/book2/Chapter7.html" } + ] + } + ] + +Some notes: + +* It is not a good idea to include a top-level URL and chapter-level URLs in the same textbook configuration. + +Linking from Content +-------------------- + +It is possible to add links to specific pages in a textbook by using a URL that encodes the index of the textbook, the chapter (if chapters are used), and the page number. For a book with no chapters, the URL is of the form `/course/htmlbook/${bookindex}`. For a book with chapters, use `/course/htmlbook/${bookindex}/chapter/${chapter}` for a specific chapter, or `/course/htmlbook/${bookindex}` will default to the first chapter. + +For example, for the book with no chapters configured above, the textbook can be reached using the URL `/course/htmlbook/0`. Reaching the third chapter of the second book is accomplished with `/course/htmlbook/1/chapter/3`. + +You can use a `customtag` to create a template for such links. For example, you can create a `htmlbook` template in the `customtag` directory, containing: + +.. code-block:: xml + + More information given in the text. + +And a `htmlchapter` template containing: + +.. code-block:: xml + + More information given in the text. + +The example pages can then be linked using the `customtag` element: + +.. code-block:: xml + + + + PDF-based Textbooks =================== diff --git a/doc/public/course_data_formats/word_cloud/word_cloud.png b/doc/public/course_data_formats/word_cloud/word_cloud.png new file mode 100644 index 0000000000..07b7292b5e Binary files /dev/null and b/doc/public/course_data_formats/word_cloud/word_cloud.png differ diff --git a/doc/public/course_data_formats/word_cloud/word_cloud.rst b/doc/public/course_data_formats/word_cloud/word_cloud.rst new file mode 100644 index 0000000000..5c3d31e149 --- /dev/null +++ b/doc/public/course_data_formats/word_cloud/word_cloud.rst @@ -0,0 +1,59 @@ +********************************************** +Xml format of "Word Cloud" module [xmodule] +********************************************** + +.. module:: word_cloud + +Format description +================== + +The main tag of Word Cloud module input is: + +.. code-block:: xml + + + +The following attributes can be specified for this tag:: + + [display_name| AUTOGENERATE] – Display name of xmodule. When this attribute is not defined - display name autogenerate with some hash. + [num_inputs| 5] – Number of inputs. + [num_top_words| 250] – Number of max words, which will be displayed. + [display_student_percents| True] – Display usage percents for each word on the same line together with words. + +.. note:: + + Percent is shown always when mouse over the word in cloud. + +.. note:: + + Possible answer for boolean type attributes: + True – "True", "true", "T", "t", "1" + False – "False", "false", "F", "f", "0" + +.. note:: + + If you want to use the same word cloud (the same storage of words), you must use the same display_name value. + + +Code Example +============ + +Examples of word_cloud without all attributes (all attributes get by default) +----------------------------------------------------------------------------- + +.. code-block:: xml + + + +Examples of poll with all attributes +------------------------------------ + +.. code-block:: xml + + + +Screenshots +=========== + +.. image:: word_cloud.png + :width: 50% diff --git a/doc/public/index.rst b/doc/public/index.rst index ee681a822e..064b3ff443 100644 --- a/doc/public/index.rst +++ b/doc/public/index.rst @@ -26,6 +26,7 @@ Specific Problem Types course_data_formats/graphical_slider_tool/graphical_slider_tool.rst course_data_formats/poll_module/poll_module.rst course_data_formats/conditional_module/conditional_module.rst + course_data_formats/word_cloud/word_cloud.rst course_data_formats/custom_response.rst diff --git a/doc/test_pyramid.png b/doc/test_pyramid.png new file mode 100644 index 0000000000..5c0eea5762 Binary files /dev/null and b/doc/test_pyramid.png differ diff --git a/doc/testing.md b/doc/testing.md index 694a9e8231..4d286b1bcc 100644 --- a/doc/testing.md +++ b/doc/testing.md @@ -1,68 +1,216 @@ # Testing -Testing is good. Here is some useful info about how we set up tests. -More info is [on the wiki](https://edx-wiki.atlassian.net/wiki/display/ENG/Test+Engineering) +## Overview -## Backend code +We maintain three kinds of tests: unit tests, integration tests, +and acceptance tests. -- The python unit tests can be run via rake tasks. -See development.md for more info on how to do this. +### Unit Tests -## Frontend code +* Each test case should be concise: setup, execute, check, and teardown. +If you find yourself writing tests with many steps, consider refactoring +the unit under tests into smaller units, and then testing those individually. -### Jasmine +* As a rule of thumb, your unit tests should cover every code branch. -We're using Jasmine to unit/integration test the JavaScript files. -More info [on the wiki](https://edx-wiki.atlassian.net/wiki/display/ENG/Jasmine) +* Mock or patch external dependencies. +We use [voidspace mock](http://www.voidspace.org.uk/python/mock/). -All the specs are written in CoffeeScript to be consistent with the code. -To access the test cases, start the server using the settings file **jasmine.py** using this command: - `rake django-admin[runserver,lms,jasmine,12345]` +* We unit test Python code (using [unittest](http://docs.python.org/2/library/unittest.html)) and +Javascript (using [Jasmine](http://pivotal.github.io/jasmine/)) -Then navigate to `http://localhost:12345/_jasmine/` to see the test results. +### Integration Tests +* Test several units at the same time. +Note that you can still mock or patch dependencies +that are not under test! For example, you might test that +`LoncapaProblem`, `NumericalResponse`, and `CorrectMap` in the +`capa` package work together, while still mocking out template rendering. -All the JavaScript codes must have test coverage. Both CMS and LMS -has its own test directory in `{cms,lms}/static/coffee/spec` If you haven't -written a JavaScript test before, you can look at those example files as a -starting point. Also, these materials might be helpful for you: +* Use integration tests to ensure that units are hooked up correctly. +You do not need to test every possible input--that's what unit +tests are for. Instead, focus on testing the "happy path" +to verify that the components work together correctly. -CMS Note: For consistency, you're advised to use the same directory structure -for implementation and test. For example, test for `src/views/module.coffee` +* Many of our tests use the [Django test client](https://docs.djangoproject.com/en/dev/topics/testing/overview/) to simulate +HTTP requests to the server. + +### UI Acceptance Tests +* Use these to test that major program features are working correctly. + +* We use [lettuce](http://lettuce.it/) to write BDD-style tests. Most of +these tests simulate user interactions through the browser using +[splinter](http://splinter.cobrateam.info/). + +Overall, you want to write the tests that **maximize coverage** +while **minimizing maintenance**. +In practice, this usually means investing heavily +in unit tests, which tend to be the most robust to changes in the code base. + +![Test Pyramid](test_pyramid.png) + +The pyramid above shows the relative number of unit tests, integration tests, +and acceptance tests. Most of our tests are unit tests or integration tests. + +## Test Locations + +* Python unit and integration tests: Located in +subpackages called `tests`. +For example, the tests for the `capa` package are located in +`common/lib/capa/capa/tests`. + +* Javascript unit tests: Located in `spec` folders. For example, +`common/lib/xmodule/xmodule/js/spec` and `{cms,lms}/static/coffee/spec` +For consistency, you should use the same directory structure for implementation +and test. For example, the test for `src/views/module.coffee` should be written in `spec/views/module_spec.coffee`. -* http://pivotal.github.com/jasmine -* http://railscasts.com/episodes/261-testing-javascript-with-jasmine?view=asciicast -* http://a-developer-life.blogspot.com/2011/05/jasmine-part-1-unit-testing-javascript.html +* UI acceptance tests: + - Set up and helper methods: `common/djangoapps/terrain` + - Tests: located in `features` subpackage within a Django app. + For example: `lms/djangoapps/courseware/features` -If you're finishing a feature that contains JavaScript code snippets and do not -sure how to test, please feel free to open up a pull request and asking people -for help. (However, the best way to do it would be writing your test first, then -implement your feature - Test Driven Development.) -### BDD style acceptance tests with Lettuce +## Factories -We're using Lettuce for end user acceptance testing of features. -More info [on the wiki](https://edx-wiki.atlassian.net/wiki/display/ENG/Lettuce+Acceptance+Testing) +Many tests delegate set-up to a "factory" class. For example, +there are factories for creating courses, problems, and users. +This encapsulates set-up logic from tests. -Lettuce is a port of Cucumber. We're using it to drive Splinter, which is a python wrapper to Selenium. -To execute the automated test scripts, you'll need to start up the django server separately, then launch the tests. -Do both use the settings file named **acceptance.py**. +Factories are often implemented using [FactoryBoy](https://readthedocs.org/projects/factoryboy/) -What this will do is to use a sqllite database named mitx_all/db/test_mitx.db. -That way it can be flushed etc. without messing up your dev db. -Note that this also means that you need to syncdb and migrate the db first before starting the server to initialize it if it does not yet exist. +In general, factories should be located close to the code they use. +For example, the factory for creating problem XML definitions + is located in `common/lib/capa/capa/tests/response_xml_factory.py` +because the `capa` package handles problem XML. -1. Set up the test database (only needs to be done once): - rm ../db/test_mitx.db - rake django-admin[syncdb,lms,acceptance,--noinput] - rake django-admin[migrate,lms,acceptance,--noinput] -2. Start up the django server separately in a shell - rake lms[acceptance] +# Running Tests -3. Then in another shell, run the tests in different ways as below. Lettuce comes with a new django-admin command called _harvest_. See the [lettuce django docs](http://lettuce.it/recipes/django-lxml.html) for more details. -* All tests in a specified feature folder: `django-admin.py harvest --no-server --settings=lms.envs.acceptance --pythonpath=. lms/djangoapps/portal/features/` -* Only the specified feature's scenarios: `django-admin.py harvest --no-server --settings=lms.envs.acceptance --pythonpath=. lms/djangoapps/courseware/features/high-level-tabs.feature` +Before running tests, ensure that you have all the dependencies. You can install dependencies using: -4. Troubleshooting -* If you get an error msg that says something about harvest not being a command, you probably are missing a requirement. Pip install (test-requirements.txt) and/or brew install as needed. \ No newline at end of file + rake install_prereqs + + +## Running Python Unit tests + +We use [nose](https://nose.readthedocs.org/en/latest/) through +the [django-nose plugin](https://pypi.python.org/pypi/django-nose) +to run the test suite. + +You can run tests using `rake` commands. For example, + + rake test + +runs all the tests. It also runs `collectstatic`, which prepares the static files used by the site (for example, compiling Coffeescript to Javascript). + +You can also run the tests without `collectstatic`, which tends to be faster: + + rake fasttest_lms + +or + + rake fasttest_cms + +xmodule can be tested independently, with this: + + rake test_common/lib/xmodule + +other module level tests include + +* `rake test_common/lib/capa` +* `rake test_common/lib/calc` + +To run a single django test class: + + rake test_lms[courseware.tests.tests:testViewAuth] + +To run a single django test: + + rake test_lms[courseware.tests.tests:TestViewAuth.test_dark_launch] + +To run a single nose test file: + + nosetests common/lib/xmodule/xmodule/tests/test_stringify.py + +To run a single nose test: + + nosetests common/lib/xmodule/xmodule/tests/test_stringify.py:test_stringify + + +Very handy: if you uncomment the `pdb=1` line in `setup.cfg`, it will drop you into pdb on error. This lets you go up and down the stack and see what the values of the variables are. Check out [the pdb documentation](http://docs.python.org/library/pdb.html) + +### Running Javascript Unit Tests + +These commands start a development server with jasmine testing enabled, and launch your default browser +pointing to those tests + + rake browse_jasmine_{lms,cms} + +To run the tests headless, you must install [phantomjs](http://phantomjs.org/download.html), then run: + + rake phantomjs_jasmine_{lms,cms} + +If the `phantomjs` binary is not on the path, set the `PHANTOMJS_PATH` environment variable to point to it + + PHANTOMJS_PATH=/path/to/phantomjs rake phantomjs_jasmine_{lms,cms} + +Once you have run the `rake` command, your browser should open to +to `http://localhost/_jasmine/`, which displays the test results. + +**Troubleshooting**: If you get an error message while running the `rake` task, +try running `bundle install` to install the required ruby gems. + +### Running Acceptance Tests + +We use [Lettuce](http://lettuce.it/) for acceptance testing. +Most of our tests use [Splinter](http://splinter.cobrateam.info/) +to simulate UI browser interactions. Splinter, in turn, +uses [Selenium](http://docs.seleniumhq.org/) to control the Chrome browser. + +**Prerequisite**: You must have [ChromeDriver](https://code.google.com/p/selenium/wiki/ChromeDriver) +installed to run the tests in Chrome. The tests are confirmed to run +with Chrome (not Chromium) version 26.0.0.1410.63 with ChromeDriver +version r195636. + +To run all the acceptance tests: + + rake test_acceptance_lms + rake test_acceptance_cms + +To test only a specific feature: + + rake test_acceptance_lms[lms/djangoapps/courseware/features/problems.feature] + +To start the debugger on failure, add the `--pdb` option: + + rake test_acceptance_lms["lms/djangoapps/courseware/features/problems.feature --pdb"] + +To run tests faster by not collecting static files, you can use +`rake fasttest_acceptance_lms` and `rake fasttest_acceptance_cms`. + +**Note**: The acceptance tests can *not* currently run in parallel. + +## Viewing Test Coverage + +We currently collect test coverage information for Python unit/integration tests. + +To view test coverage: + +1. Run the test suite: + + rake test + +2. Generate reports: + + rake coverage:html + +3. HTML reports are located in the `reports` folder. + + +## Testing using queue servers + +When testing problems that use a queue server on AWS (e.g. sandbox-xqueue.edx.org), you'll need to run your server on your public IP, like so. + +`django-admin.py runserver --settings=lms.envs.dev --pythonpath=. 0.0.0.0:8000` + +When you connect to the LMS, you need to use the public ip. Use `ifconfig` to figure out the number, and connect e.g. to `http://18.3.4.5:8000/` diff --git a/docs/source/xmodule.rst b/docs/source/xmodule.rst index 45caa82c30..d68ab779f6 100644 --- a/docs/source/xmodule.rst +++ b/docs/source/xmodule.rst @@ -165,6 +165,13 @@ Video :members: :show-inheritance: +Word Cloud +========== + +.. automodule:: xmodule.word_cloud_module + :members: + :show-inheritance: + X = diff --git a/fixtures/anonymize_fixtures.py b/fixtures/anonymize_fixtures.py deleted file mode 100755 index ba62652de5..0000000000 --- a/fixtures/anonymize_fixtures.py +++ /dev/null @@ -1,98 +0,0 @@ -#! /usr/bin/env python - -import sys -import json -import random -import copy -from collections import defaultdict -from argparse import ArgumentParser, FileType -from datetime import datetime - -def generate_user(user_number): - return { - "pk": user_number, - "model": "auth.user", - "fields": { - "status": "w", - "last_name": "Last", - "gold": 0, - "is_staff": False, - "user_permissions": [], - "interesting_tags": "", - "email_key": None, - "date_joined": "2012-04-26 11:36:39", - "first_name": "", - "email_isvalid": False, - "avatar_type": "n", - "website": "", - "is_superuser": False, - "date_of_birth": None, - "last_login": "2012-04-26 11:36:48", - "location": "", - "new_response_count": 0, - "email": "user{num}@example.com".format(num=user_number), - "username": "user{num}".format(num=user_number), - "is_active": True, - "consecutive_days_visit_count": 0, - "email_tag_filter_strategy": 1, - "groups": [], - "password": "sha1$90e6f$562a1d783a0c47ce06ebf96b8c58123a0671bbf0", - "silver": 0, - "bronze": 0, - "questions_per_page": 10, - "about": "", - "show_country": True, - "country": "", - "display_tag_filter_strategy": 0, - "seen_response_count": 0, - "real_name": "", - "ignored_tags": "", - "reputation": 1, - "gravatar": "366d981a10116969c568a18ee090f44c", - "last_seen": "2012-04-26 11:36:39" - } - } - - -def parse_args(args=sys.argv[1:]): - parser = ArgumentParser() - parser.add_argument('-d', '--data', type=FileType('r'), default=sys.stdin) - parser.add_argument('-o', '--output', type=FileType('w'), default=sys.stdout) - parser.add_argument('count', type=int) - return parser.parse_args(args) - - -def main(args=sys.argv[1:]): - args = parse_args(args) - - data = json.load(args.data) - unique_students = set(entry['fields']['student'] for entry in data) - if args.count > len(unique_students) * 0.1: - raise Exception("Can't be sufficiently anonymous selecting {count} of {unique} students".format( - count=args.count, unique=len(unique_students))) - - by_problems = defaultdict(list) - for entry in data: - by_problems[entry['fields']['module_id']].append(entry) - - out_data = [] - out_pk = 1 - for name, answers in by_problems.items(): - for student_id in xrange(args.count): - sample = random.choice(answers) - data = copy.deepcopy(sample) - data["fields"]["student"] = student_id + 1 - data["fields"]["created"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - data["fields"]["modified"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - data["pk"] = out_pk - out_pk += 1 - out_data.append(data) - - for student_id in xrange(args.count): - out_data.append(generate_user(student_id)) - - json.dump(out_data, args.output, indent=2) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/fixtures/pm.json b/fixtures/pm.json deleted file mode 100644 index 5ecb839093..0000000000 --- a/fixtures/pm.json +++ /dev/null @@ -1 +0,0 @@ -[{"pk": 1, "model": "user.userprofile", "fields": {"name": "pm", "language": "pm", "courseware": "course.xml", "meta": "", "location": "pm", "user": 1}}, {"pk": 1, "model": "auth.user", "fields": {"status": "w", "last_name": "", "gold": 0, "is_staff": true, "user_permissions": [], "interesting_tags": "", "email_key": null, "date_joined": "2012-01-23 17:03:54", "first_name": "", "email_isvalid": false, "avatar_type": "n", "website": "", "is_superuser": true, "date_of_birth": null, "last_login": "2012-01-23 17:04:16", "location": "", "new_response_count": 0, "email": "pmitros@csail.mit.edu", "username": "pm", "is_active": true, "consecutive_days_visit_count": 0, "email_tag_filter_strategy": 1, "groups": [], "password": "sha1$a3e96$dbabbd114f0da01bce2cc2adcafa2ca651c7ae0a", "silver": 0, "bronze": 0, "questions_per_page": 10, "about": "", "show_country": false, "country": "", "display_tag_filter_strategy": 0, "seen_response_count": 0, "real_name": "", "ignored_tags": "", "reputation": 1, "gravatar": "7a591afd0cc7972fdbe5e12e26af352a", "last_seen": "2012-01-23 17:04:41"}}, {"pk": 1, "model": "user.userprofile", "fields": {"name": "pm", "language": "pm", "courseware": "course.xml", "meta": "", "location": "pm", "user": 1}}, {"pk": 1, "model": "auth.user", "fields": {"status": "w", "last_name": "", "gold": 0, "is_staff": true, "user_permissions": [], "interesting_tags": "", "email_key": null, "date_joined": "2012-01-23 17:03:54", "first_name": "", "email_isvalid": false, "avatar_type": "n", "website": "", "is_superuser": true, "date_of_birth": null, "last_login": "2012-01-23 17:04:16", "location": "", "new_response_count": 0, "email": "pmitros@csail.mit.edu", "username": "pm", "is_active": true, "consecutive_days_visit_count": 0, "email_tag_filter_strategy": 1, "groups": [], "password": "sha1$a3e96$dbabbd114f0da01bce2cc2adcafa2ca651c7ae0a", "silver": 0, "bronze": 0, "questions_per_page": 10, "about": "", "show_country": false, "country": "", "display_tag_filter_strategy": 0, "seen_response_count": 0, "real_name": "", "ignored_tags": "", "reputation": 1, "gravatar": "7a591afd0cc7972fdbe5e12e26af352a", "last_seen": "2012-01-23 17:04:41"}}] \ No newline at end of file diff --git a/github-requirements.txt b/github-requirements.txt deleted file mode 100644 index 468d55ce65..0000000000 --- a/github-requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -# Python libraries to install directly from github --e git://github.com/MITx/django-staticfiles.git@6d2504e5c8#egg=django-staticfiles --e git://github.com/MITx/django-pipeline.git#egg=django-pipeline --e git://github.com/MITx/django-wiki.git@e2e84558#egg=django-wiki --e git://github.com/dementrock/pystache_custom.git@776973740bdaad83a3b029f96e415a7d1e8bec2f#egg=pystache_custom-dev diff --git a/i18n/config.py b/i18n/config.py new file mode 100644 index 0000000000..4f246ed942 --- /dev/null +++ b/i18n/config.py @@ -0,0 +1,77 @@ +import os, json +from path import path + +# BASE_DIR is the working directory to execute django-admin commands from. +# Typically this should be the 'mitx' directory. +BASE_DIR = path(__file__).abspath().dirname().joinpath('..').normpath() + +# LOCALE_DIR contains the locale files. +# Typically this should be 'mitx/conf/locale' +LOCALE_DIR = BASE_DIR.joinpath('conf', 'locale') + +class Configuration: + """ + # Reads localization configuration in json format + + """ + _source_locale = 'en' + + def __init__(self, filename): + self._filename = filename + self._config = self.read_config(filename) + + def read_config(self, filename): + """ + Returns data found in config file (as dict), or raises exception if file not found + """ + if not os.path.exists(filename): + raise Exception("Configuration file cannot be found: %s" % filename) + with open(filename) as stream: + return json.load(stream) + + @property + def locales(self): + """ + Returns a list of locales declared in the configuration file, + e.g. ['en', 'fr', 'es'] + Each locale is a string. + """ + return self._config['locales'] + + @property + def source_locale(self): + """ + Returns source language. + Source language is English. + """ + return self._source_locale + + @property + def dummy_locale(self): + """ + Returns a locale to use for the dummy text, e.g. 'fr'. + Throws exception if no dummy-locale is declared. + The locale is a string. + """ + dummy = self._config.get('dummy-locale', None) + if not dummy: + raise Exception('Could not read dummy-locale from configuration file.') + return dummy + + def get_messages_dir(self, locale): + """ + Returns the name of the directory holding the po files for locale. + Example: mitx/conf/locale/fr/LC_MESSAGES + """ + return LOCALE_DIR.joinpath(locale, 'LC_MESSAGES') + + @property + def source_messages_dir(self): + """ + Returns the name of the directory holding the source-language po files (English). + Example: mitx/conf/locale/en/LC_MESSAGES + """ + return self.get_messages_dir(self.source_locale) + + +CONFIGURATION = Configuration(LOCALE_DIR.joinpath('config').normpath()) diff --git a/i18n/converter.py b/i18n/converter.py new file mode 100644 index 0000000000..63d8f83e00 --- /dev/null +++ b/i18n/converter.py @@ -0,0 +1,65 @@ +import re +import itertools + +class Converter: + """Converter is an abstract class that transforms strings. + It hides embedded tags (HTML or Python sequences) from transformation + + To implement Converter, provide implementation for inner_convert_string() + + Strategy: + 1. extract tags embedded in the string + a. use the index of each extracted tag to re-insert it later + b. replace tags in string with numbers (<0>, <1>, etc.) + c. save extracted tags in a separate list + 2. convert string + 3. re-insert the extracted tags + + """ + + # matches tags like these: + # HTML: , ,
, + # Python: %(date)s, %(name)s + tag_pattern = re.compile(r'(<[-\w" .:?=/]*>)|({[^}]*})|(%\([^)]*\)\w)', re.I) + + def convert(self, string): + """Returns: a converted tagged string + param: string (contains html tags) + + Don't replace characters inside tags + """ + (string, tags) = self.detag_string(string) + string = self.inner_convert_string(string) + string = self.retag_string(string, tags) + return string + + def detag_string(self, string): + """Extracts tags from string. + + returns (string, list) where + string: string has tags replaced by indices (
... => <0>, <1>, <2>, etc.) + list: list of the removed tags ('
', '', '') + """ + counter = itertools.count(0) + count = lambda m: '<%s>' % counter.next() + tags = self.tag_pattern.findall(string) + tags = [''.join(tag) for tag in tags] + (new, nfound) = self.tag_pattern.subn(count, string) + if len(tags) != nfound: + raise Exception('tags dont match:'+string) + return (new, tags) + + def retag_string(self, string, tags): + """substitutes each tag back into string, into occurrences of <0>, <1> etc""" + for (i, tag) in enumerate(tags): + p = '<%s>' % i + string = re.sub(p, tag, string, 1) + return string + + + # ------------------------------ + # Customize this in subclasses of Converter + + def inner_convert_string(self, string): + return string # do nothing by default + diff --git a/i18n/dummy.py b/i18n/dummy.py new file mode 100644 index 0000000000..78bfdc3b58 --- /dev/null +++ b/i18n/dummy.py @@ -0,0 +1,132 @@ +from converter import Converter + +# Creates new localization properties files in a dummy language +# Each property file is derived from the equivalent en_US file, except +# 1. Every vowel is replaced with an equivalent with extra accent marks +# 2. Every string is padded out to +30% length to simulate verbose languages (e.g. German) +# to see if layout and flows work properly +# 3. Every string is terminated with a '#' character to make it easier to detect truncation + + +# -------------------------------- +# Example use: +# >>> from dummy import Dummy +# >>> c = Dummy() +# >>> c.convert("hello my name is Bond, James Bond") +# u'h\xe9ll\xf6 my n\xe4m\xe9 \xefs B\xf6nd, J\xe4m\xe9s B\xf6nd Lorem i#' +# +# >>> c.convert('don\'t convert tag ids') +# u'd\xf6n\'t \xe7\xf6nv\xe9rt t\xe4g \xefds Lorem ipsu#' +# +# >>> c.convert('don\'t convert %(name)s tags on %(date)s') +# u"d\xf6n't \xe7\xf6nv\xe9rt %(name)s t\xe4gs \xf6n %(date)s Lorem ips#" + + +# Substitute plain characters with accented lookalikes. +# http://tlt.its.psu.edu/suggestions/international/web/codehtml.html#accent +TABLE = {'A': u'\xC0', + 'a': u'\xE4', + 'b': u'\xDF', + 'C': u'\xc7', + 'c': u'\xE7', + 'E': u'\xC9', + 'e': u'\xE9', + 'I': U'\xCC', + 'i': u'\xEF', + 'O': u'\xD8', + 'o': u'\xF6', + 'u': u'\xFC' + } + + + +# The print industry's standard dummy text, in use since the 1500s +# see http://www.lipsum.com/ +LOREM = ' Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed ' \ + 'do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad ' \ + 'minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ' \ + 'ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate ' \ + 'velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat ' \ + 'cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. ' + +# To simulate more verbose languages (like German), pad the length of a string +# by a multiple of PAD_FACTOR +PAD_FACTOR = 1.3 + + +class Dummy (Converter): + """ + A string converter that generates dummy strings with fake accents + and lorem ipsum padding. + """ + + def convert(self, string): + result = Converter.convert(self, string) + return self.pad(result) + + def inner_convert_string(self, string): + for (k,v) in TABLE.items(): + string = string.replace(k, v) + return string + + + def pad(self, string): + """add some lorem ipsum text to the end of string""" + size = len(string) + if size < 7: + target = size*3 + else: + target = int(size*PAD_FACTOR) + return string + self.terminate(LOREM[:(target-size)]) + + def terminate(self, string): + """replaces the final char of string with #""" + return string[:-1]+'#' + + def init_msgs(self, msgs): + """ + Make sure the first msg in msgs has a plural property. + msgs is list of instances of polib.POEntry + """ + if len(msgs)==0: + return + headers = msgs[0].get_property('msgstr') + has_plural = len([header for header in headers if header.find('Plural-Forms:') == 0])>0 + if not has_plural: + # Apply declaration for English pluralization rules + plural = "Plural-Forms: nplurals=2; plural=(n != 1);\\n" + headers.append(plural) + + + def convert_msg(self, msg): + """ + Takes one POEntry object and converts it (adds a dummy translation to it) + msg is an instance of polib.POEntry + """ + source = msg.msgid + if len(source)==0: + # don't translate empty string + return + + plural = msg.msgid_plural + if len(plural)>0: + # translate singular and plural + foreign_single = self.convert(source) + foreign_plural = self.convert(plural) + plural = {'0': self.final_newline(source, foreign_single), + '1': self.final_newline(plural, foreign_plural)} + msg.msgstr_plural = plural + return + else: + foreign = self.convert(source) + msg.msgstr = self.final_newline(source, foreign) + + def final_newline(self, original, translated): + """ Returns a new translated string. + If last char of original is a newline, make sure translation + has a newline too. + """ + if len(original)>1: + if original[-1]=='\n' and translated[-1]!='\n': + return translated + '\n' + return translated diff --git a/i18n/execute.py b/i18n/execute.py new file mode 100644 index 0000000000..8e7f0f52de --- /dev/null +++ b/i18n/execute.py @@ -0,0 +1,47 @@ +import os, subprocess, logging + +from config import CONFIGURATION, BASE_DIR + +LOG = logging.getLogger(__name__) + +def execute(command, working_directory=BASE_DIR): + """ + Executes shell command in a given working_directory. + Command is a string to pass to the shell. + Output is ignored. + """ + LOG.info(command) + subprocess.call(command.split(' '), cwd=working_directory) + + +def call(command, working_directory=BASE_DIR): + """ + Executes shell command in a given working_directory. + Command is a string to pass to the shell. + Returns a tuple of two strings: (stdout, stderr) + + """ + LOG.info(command) + p = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=working_directory) + out, err = p.communicate() + return (out, err) + +def create_dir_if_necessary(pathname): + dirname = os.path.dirname(pathname) + if not os.path.exists(dirname): + os.makedirs(dirname) + + +def remove_file(filename, verbose=True): + """ + Attempt to delete filename. + log is boolean. If true, removal is logged. + Log a warning if file does not exist. + Logging filenames are releative to BASE_DIR to cut down on noise in output. + """ + if verbose: + LOG.info('Deleting file %s' % os.path.relpath(filename, BASE_DIR)) + if not os.path.exists(filename): + LOG.warn("File does not exist: %s" % os.path.relpath(filename, BASE_DIR)) + else: + os.remove(filename) diff --git a/i18n/extract.py b/i18n/extract.py new file mode 100755 index 0000000000..c28c3868e2 --- /dev/null +++ b/i18n/extract.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python + +""" +See https://edx-wiki.atlassian.net/wiki/display/ENG/PO+File+workflow + + This task extracts all English strings from all source code + and produces three human-readable files: + conf/locale/en/LC_MESSAGES/django-partial.po + conf/locale/en/LC_MESSAGES/djangojs.po + conf/locale/en/LC_MESSAGES/mako.po + + This task will clobber any existing django.po file. + This is because django-admin.py makemessages hardcodes this filename + and it cannot be overridden. + +""" + +import os, sys, logging +from datetime import datetime +from polib import pofile + +from config import BASE_DIR, LOCALE_DIR, CONFIGURATION +from execute import execute, create_dir_if_necessary, remove_file + +# BABEL_CONFIG contains declarations for Babel to extract strings from mako template files +# Use relpath to reduce noise in logs +BABEL_CONFIG = BASE_DIR.relpathto(LOCALE_DIR.joinpath('babel.cfg')) + +# Strings from mako template files are written to BABEL_OUT +# Use relpath to reduce noise in logs +BABEL_OUT = BASE_DIR.relpathto(CONFIGURATION.source_messages_dir.joinpath('mako.po')) + +SOURCE_WARN = 'This English source file is machine-generated. Do not check it into github' + +LOG = logging.getLogger(__name__) + +def main (): + logging.basicConfig(stream=sys.stdout, level=logging.INFO) + create_dir_if_necessary(LOCALE_DIR) + source_msgs_dir = CONFIGURATION.source_messages_dir + + remove_file(source_msgs_dir.joinpath('django.po')) + generated_files = ('django-partial.po', 'djangojs.po', 'mako.po') + for filename in generated_files: + remove_file(source_msgs_dir.joinpath(filename)) + + + # Extract strings from mako templates + babel_mako_cmd = 'pybabel extract -F %s -c "TRANSLATORS:" . -o %s' % (BABEL_CONFIG, BABEL_OUT) + + # Extract strings from django source files + make_django_cmd = 'django-admin.py makemessages -l en --ignore=src/* --ignore=i18n/* ' \ + + '--extension html' + + # Extract strings from javascript source files + make_djangojs_cmd = 'django-admin.py makemessages -l en -d djangojs --ignore=src/* ' \ + + '--ignore=i18n/* --extension js' + execute(babel_mako_cmd, working_directory=BASE_DIR) + execute(make_django_cmd, working_directory=BASE_DIR) + # makemessages creates 'django.po'. This filename is hardcoded. + # Rename it to django-partial.po to enable merging into django.po later. + os.rename(source_msgs_dir.joinpath('django.po'), + source_msgs_dir.joinpath('django-partial.po')) + execute(make_djangojs_cmd, working_directory=BASE_DIR) + + for filename in generated_files: + LOG.info('Cleaning %s' % filename) + po = pofile(source_msgs_dir.joinpath(filename)) + # replace default headers with edX headers + fix_header(po) + # replace default metadata with edX metadata + fix_metadata(po) + # remove key strings which belong in messages.po + strip_key_strings(po) + po.save() + +# By default, django-admin.py makemessages creates this header: +""" +SOME DESCRIPTIVE TITLE. +Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +This file is distributed under the same license as the PACKAGE package. +FIRST AUTHOR , YEAR. +""" + +def fix_header(po): + """ + Replace default headers with edX headers + """ + po.metadata_is_fuzzy = [] # remove [u'fuzzy'] + header = po.header + fixes = ( + ('SOME DESCRIPTIVE TITLE', 'edX translation file\n' + SOURCE_WARN), + ('Translations template for PROJECT.', 'edX translation file\n' + SOURCE_WARN), + ('YEAR', '%s' % datetime.utcnow().year), + ('ORGANIZATION', 'edX'), + ("THE PACKAGE'S COPYRIGHT HOLDER", "EdX"), + ('This file is distributed under the same license as the PROJECT project.', + 'This file is distributed under the GNU AFFERO GENERAL PUBLIC LICENSE.'), + ('This file is distributed under the same license as the PACKAGE package.', + 'This file is distributed under the GNU AFFERO GENERAL PUBLIC LICENSE.'), + ('FIRST AUTHOR ', + 'EdX Team ') + ) + for (src, dest) in fixes: + header = header.replace(src, dest) + po.header = header + +# By default, django-admin.py makemessages creates this metadata: +""" +{u'PO-Revision-Date': u'YEAR-MO-DA HO:MI+ZONE', + u'Language': u'', + u'Content-Transfer-Encoding': u'8bit', + u'Project-Id-Version': u'PACKAGE VERSION', + u'Report-Msgid-Bugs-To': u'', + u'Last-Translator': u'FULL NAME ', + u'Language-Team': u'LANGUAGE ', + u'POT-Creation-Date': u'2013-04-25 14:14-0400', + u'Content-Type': u'text/plain; charset=UTF-8', + u'MIME-Version': u'1.0'} +""" + +def fix_metadata(po): + """ + Replace default metadata with edX metadata + """ + fixes = {'PO-Revision-Date': datetime.utcnow(), + 'Report-Msgid-Bugs-To': 'translation_team@edx.org', + 'Project-Id-Version': '0.1a', + 'Language' : 'en', + 'Last-Translator' : '', + 'Language-Team': 'translation team ', + } + po.metadata.update(fixes) + +def strip_key_strings(po): + """ + Removes all entries in PO which are key strings. + These entries should appear only in messages.po, not in any other po files. + """ + newlist = [entry for entry in po if not is_key_string(entry.msgid)] + del po[:] + po += newlist + +def is_key_string(string): + """ + returns True if string is a key string. + Key strings begin with underscore. + """ + return len(string)>1 and string[0]=='_' + +if __name__ == '__main__': + main() diff --git a/i18n/generate.py b/i18n/generate.py new file mode 100755 index 0000000000..65c65c00d6 --- /dev/null +++ b/i18n/generate.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python + +""" + See https://edx-wiki.atlassian.net/wiki/display/ENG/PO+File+workflow + + + This task merges and compiles the human-readable .pofiles on the + local filesystem into machine-readable .mofiles. This is typically + necessary as part of the build process since these .mofiles are + needed by Django when serving the web app. + + The configuration file (in mitx/conf/locale/config) specifies which + languages to generate. +""" + +import os, sys, logging +from polib import pofile + +from config import BASE_DIR, CONFIGURATION +from execute import execute + +LOG = logging.getLogger(__name__) + +def merge(locale, target='django.po', fail_if_missing=True): + """ + For the given locale, merge django-partial.po, messages.po, mako.po -> django.po + target is the resulting filename + If fail_if_missing is True, and the files to be merged are missing, + throw an Exception. + If fail_if_missing is False, and the files to be merged are missing, + just return silently. + """ + LOG.info('Merging locale={0}'.format(locale)) + locale_directory = CONFIGURATION.get_messages_dir(locale) + files_to_merge = ('django-partial.po', 'messages.po', 'mako.po') + try: + validate_files(locale_directory, files_to_merge) + except Exception, e: + if not fail_if_missing: + return + raise e + + # merged file is merged.po + merge_cmd = 'msgcat -o merged.po ' + ' '.join(files_to_merge) + execute(merge_cmd, working_directory=locale_directory) + + # clean up redunancies in the metadata + merged_filename = locale_directory.joinpath('merged.po') + clean_metadata(merged_filename) + + # rename merged.po -> django.po (default) + django_filename = locale_directory.joinpath(target) + os.rename(merged_filename, django_filename) # can't overwrite file on Windows + +def clean_metadata(file): + """ + Clean up redundancies in the metadata caused by merging. + This reads in a PO file and simply saves it back out again. + """ + pofile(file).save() + +def validate_files(dir, files_to_merge): + """ + Asserts that the given files exist. + files_to_merge is a list of file names (no directories). + dir is the directory (a path object from path.py) in which the files should appear. + raises an Exception if any of the files are not in dir. + """ + for path in files_to_merge: + pathname = dir.joinpath(path) + if not pathname.exists(): + raise Exception("I18N: Cannot generate because file not found: {0}".format(pathname)) + +def main (): + logging.basicConfig(stream=sys.stdout, level=logging.INFO) + + for locale in CONFIGURATION.locales: + merge(locale) + # Dummy text is not required. Don't raise exception if files are missing. + merge(CONFIGURATION.dummy_locale, fail_if_missing=False) + compile_cmd = 'django-admin.py compilemessages' + execute(compile_cmd, working_directory=BASE_DIR) + +if __name__ == '__main__': + main() diff --git a/i18n/make_dummy.py b/i18n/make_dummy.py new file mode 100755 index 0000000000..6c14edd45a --- /dev/null +++ b/i18n/make_dummy.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python + +# Generate test translation files from human-readable po files. +# +# Dummy language is specified in configuration file (see config.py) +# two letter language codes reference: +# see http://www.loc.gov/standards/iso639-2/php/code_list.php +# +# Django will not localize in languages that django itself has not been +# localized for. So we are using a well-known language (default='fr'). +# +# po files can be generated with this: +# django-admin.py makemessages --all --extension html -l en + +# Usage: +# +# $ ./make_dummy.py +# +# $ ./make_dummy.py ../conf/locale/en/LC_MESSAGES/django.po +# +# generates output to +# mitx/conf/locale/fr/LC_MESSAGES/django.po + +import os, sys +import polib +from dummy import Dummy +from config import CONFIGURATION +from execute import create_dir_if_necessary + +def main(file, locale): + """ + Takes a source po file, reads it, and writes out a new po file + in :param locale: containing a dummy translation. + """ + if not os.path.exists(file): + raise IOError('File does not exist: %s' % file) + pofile = polib.pofile(file) + converter = Dummy() + converter.init_msgs(pofile.translated_entries()) + for msg in pofile: + converter.convert_msg(msg) + new_file = new_filename(file, locale) + create_dir_if_necessary(new_file) + pofile.save(new_file) + +def new_filename(original_filename, new_locale): + """Returns a filename derived from original_filename, using new_locale as the locale""" + orig_dir = os.path.dirname(original_filename) + msgs_dir = os.path.basename(orig_dir) + orig_file = os.path.basename(original_filename) + return os.path.abspath(os.path.join(orig_dir, + '../..', + new_locale, + msgs_dir, + orig_file)) + +if __name__ == '__main__': + # required arg: file + if len(sys.argv)<2: + raise Exception("missing file argument") + # optional arg: locale + if len(sys.argv)<3: + locale = CONFIGURATION.get_dummy_locale() + else: + locale = sys.argv[2] + main(sys.argv[1], locale) diff --git a/i18n/tests/__init__.py b/i18n/tests/__init__.py new file mode 100644 index 0000000000..ee6283376e --- /dev/null +++ b/i18n/tests/__init__.py @@ -0,0 +1,6 @@ +from test_config import TestConfiguration +from test_extract import TestExtract +from test_generate import TestGenerate +from test_converter import TestConverter +from test_dummy import TestDummy +import test_validate diff --git a/i18n/tests/test_config.py b/i18n/tests/test_config.py new file mode 100644 index 0000000000..bcec6ac354 --- /dev/null +++ b/i18n/tests/test_config.py @@ -0,0 +1,33 @@ +import os +from unittest import TestCase + +from config import Configuration, LOCALE_DIR, CONFIGURATION + +class TestConfiguration(TestCase): + """ + Tests functionality of i18n/config.py + """ + + def test_config(self): + config_filename = os.path.normpath(os.path.join(LOCALE_DIR, 'config')) + config = Configuration(config_filename) + self.assertEqual(config.source_locale, 'en') + + def test_no_config(self): + config_filename = os.path.normpath(os.path.join(LOCALE_DIR, 'no_such_file')) + with self.assertRaises(Exception): + Configuration(config_filename) + + def test_valid_configuration(self): + """ + Make sure we have a valid configuration file, + and that it contains an 'en' locale. + Also check values of dummy_locale and source_locale. + """ + self.assertIsNotNone(CONFIGURATION) + locales = CONFIGURATION.locales + self.assertIsNotNone(locales) + self.assertIsInstance(locales, list) + self.assertIn('en', locales) + self.assertEqual('fr', CONFIGURATION.dummy_locale) + self.assertEqual('en', CONFIGURATION.source_locale) diff --git a/i18n/tests/test_converter.py b/i18n/tests/test_converter.py new file mode 100644 index 0000000000..4dd5f02e3f --- /dev/null +++ b/i18n/tests/test_converter.py @@ -0,0 +1,42 @@ +import os +from unittest import TestCase + +import converter + +class UpcaseConverter (converter.Converter): + """ + Converts a string to uppercase. Just used for testing. + """ + def inner_convert_string(self, string): + return string.upper() + + +class TestConverter(TestCase): + """ + Tests functionality of i18n/converter.py + """ + + def test_converter(self): + """ + Tests with a simple converter (converts strings to uppercase). + Assert that embedded HTML and python tags are not converted. + """ + c = UpcaseConverter() + test_cases = ( + # no tags + ('big bad wolf', 'BIG BAD WOLF'), + # one html tag + ('big bad wolf', 'BIG BAD WOLF'), + # two html tags + ('big bad wolf', 'BIG BAD WOLF'), + # one python tag + ('big %(adjective)s wolf', 'BIG %(adjective)s WOLF'), + # two python tags + ('big %(adjective)s %(noun)s', 'BIG %(adjective)s %(noun)s'), + # both kinds of tags + ('big %(adjective)s %(noun)s', + 'BIG %(adjective)s %(noun)s'), + ) + for (source, expected) in test_cases: + result = c.convert(source) + self.assertEquals(result, expected) diff --git a/i18n/tests/test_dummy.py b/i18n/tests/test_dummy.py new file mode 100644 index 0000000000..88addb5a95 --- /dev/null +++ b/i18n/tests/test_dummy.py @@ -0,0 +1,50 @@ +import os, string, random +from unittest import TestCase +from polib import POEntry + +import dummy + + +class TestDummy(TestCase): + """ + Tests functionality of i18n/dummy.py + """ + + def setUp(self): + self.converter = dummy.Dummy() + + def test_dummy(self): + """ + Tests with a dummy converter (adds spurious accents to strings). + Assert that embedded HTML and python tags are not converted. + """ + test_cases = (("hello my name is Bond, James Bond", + u'h\xe9ll\xf6 my n\xe4m\xe9 \xefs B\xf6nd, J\xe4m\xe9s B\xf6nd Lorem i#'), + + ('don\'t convert tag ids', + u'd\xf6n\'t \xe7\xf6nv\xe9rt t\xe4g \xefds Lorem ipsu#'), + + ('don\'t convert %(name)s tags on %(date)s', + u"d\xf6n't \xe7\xf6nv\xe9rt %(name)s t\xe4gs \xf6n %(date)s Lorem ips#") + ) + for (source, expected) in test_cases: + result = self.converter.convert(source) + self.assertEquals(result, expected) + + def test_singular(self): + entry = POEntry() + entry.msgid = 'A lovely day for a cup of tea.' + expected = u'\xc0 l\xf6v\xe9ly d\xe4y f\xf6r \xe4 \xe7\xfcp \xf6f t\xe9\xe4. Lorem i#' + self.converter.convert_msg(entry) + self.assertEquals(entry.msgstr, expected) + + def test_plural(self): + entry = POEntry() + entry.msgid = 'A lovely day for a cup of tea.' + entry.msgid_plural = 'A lovely day for some cups of tea.' + expected_s = u'\xc0 l\xf6v\xe9ly d\xe4y f\xf6r \xe4 \xe7\xfcp \xf6f t\xe9\xe4. Lorem i#' + expected_p = u'\xc0 l\xf6v\xe9ly d\xe4y f\xf6r s\xf6m\xe9 \xe7\xfcps \xf6f t\xe9\xe4. Lorem ip#' + self.converter.convert_msg(entry) + result = entry.msgstr_plural + self.assertEquals(result['0'], expected_s) + self.assertEquals(result['1'], expected_p) diff --git a/i18n/tests/test_extract.py b/i18n/tests/test_extract.py new file mode 100644 index 0000000000..7e8b1a9d2b --- /dev/null +++ b/i18n/tests/test_extract.py @@ -0,0 +1,85 @@ +import os, polib +from unittest import TestCase +from nose.plugins.skip import SkipTest +from datetime import datetime, timedelta + +import extract +from config import CONFIGURATION + +# Make sure setup runs only once +SETUP_HAS_RUN = False + +class TestExtract(TestCase): + """ + Tests functionality of i18n/extract.py + """ + generated_files = ('django-partial.po', 'djangojs.po', 'mako.po') + + def setUp(self): + # Skip this test because it takes too long (>1 minute) + # TODO: figure out how to declare a "long-running" test suite + # and add this test to it. + raise SkipTest() + + global SETUP_HAS_RUN + + # Subtract 1 second to help comparisons with file-modify time succeed, + # since os.path.getmtime() is not millisecond-accurate + self.start_time = datetime.now() - timedelta(seconds=1) + super(TestExtract, self).setUp() + if not SETUP_HAS_RUN: + # Run extraction script. Warning, this takes 1 minute or more + extract.main() + SETUP_HAS_RUN = True + + def get_files (self): + """ + This is a generator. + Returns the fully expanded filenames for all extracted files + Fails assertion if one of the files doesn't exist. + """ + for filename in self.generated_files: + path = os.path.join(CONFIGURATION.source_messages_dir, filename) + exists = os.path.exists(path) + self.assertTrue(exists, msg='Missing file: %s' % filename) + if exists: + yield path + + def test_files(self): + """ + Asserts that each auto-generated file has been modified since 'extract' was launched. + Intended to show that the file has been touched by 'extract'. + """ + + for path in self.get_files(): + self.assertTrue(datetime.fromtimestamp(os.path.getmtime(path)) > self.start_time, + msg='File not recently modified: %s' % os.path.basename(path)) + + def test_is_keystring(self): + """ + Verifies is_keystring predicate + """ + entry1 = polib.POEntry() + entry2 = polib.POEntry() + entry1.msgid = "_.lms.admin.warning.keystring" + entry2.msgid = "This is not a keystring" + self.assertTrue(extract.is_key_string(entry1.msgid)) + self.assertFalse(extract.is_key_string(entry2.msgid)) + + def test_headers(self): + """Verify all headers have been modified""" + for path in self.get_files(): + po = polib.pofile(path) + header = po.header + self.assertEqual(header.find('edX translation file'), 0, + msg='Missing header in %s:\n"%s"' % \ + (os.path.basename(path), header)) + + def test_metadata(self): + """Verify all metadata has been modified""" + for path in self.get_files(): + po = polib.pofile(path) + metadata = po.metadata + value = metadata['Report-Msgid-Bugs-To'] + expected = 'translation_team@edx.org' + self.assertEquals(expected, value) diff --git a/i18n/tests/test_generate.py b/i18n/tests/test_generate.py new file mode 100644 index 0000000000..468858664f --- /dev/null +++ b/i18n/tests/test_generate.py @@ -0,0 +1,71 @@ +import os, string, random, re +from polib import pofile +from unittest import TestCase +from datetime import datetime, timedelta + +import generate +from config import CONFIGURATION + +class TestGenerate(TestCase): + """ + Tests functionality of i18n/generate.py + """ + generated_files = ('django-partial.po', 'djangojs.po', 'mako.po') + + def setUp(self): + # Subtract 1 second to help comparisons with file-modify time succeed, + # since os.path.getmtime() is not millisecond-accurate + self.start_time = datetime.now() - timedelta(seconds=1) + + def test_merge(self): + """ + Tests merge script on English source files. + """ + filename = os.path.join(CONFIGURATION.source_messages_dir, random_name()) + generate.merge(CONFIGURATION.source_locale, target=filename) + self.assertTrue(os.path.exists(filename)) + os.remove(filename) + + def test_main(self): + """ + Runs generate.main() which should merge source files, + then compile all sources in all configured languages. + Validates output by checking all .mo files in all configured languages. + .mo files should exist, and be recently created (modified + after start of test suite) + """ + generate.main() + for locale in CONFIGURATION.locales: + for filename in ('django', 'djangojs'): + mofile = filename+'.mo' + path = os.path.join(CONFIGURATION.get_messages_dir(locale), mofile) + exists = os.path.exists(path) + self.assertTrue(exists, msg='Missing file in locale %s: %s' % (locale, mofile)) + self.assertTrue(datetime.fromtimestamp(os.path.getmtime(path)) >= self.start_time, + msg='File not recently modified: %s' % path) + self.assert_merge_headers(locale) + + def assert_merge_headers(self, locale): + """ + This is invoked by test_main to ensure that it runs after + calling generate.main(). + + There should be exactly three merge comment headers + in our merged .po file. This counts them to be sure. + A merge comment looks like this: + # #-#-#-#-# django-partial.po (0.1a) #-#-#-#-# + + """ + path = os.path.join(CONFIGURATION.get_messages_dir(locale), 'django.po') + po = pofile(path) + pattern = re.compile('^#-#-#-#-#', re.M) + match = pattern.findall(po.header) + self.assertEqual(len(match), 3, + msg="Found %s (should be 3) merge comments in the header for %s" % \ + (len(match), path)) + + +def random_name(size=6): + """Returns random filename as string, like test-4BZ81W""" + chars = string.ascii_uppercase + string.digits + return 'test-' + ''.join(random.choice(chars) for x in range(size)) diff --git a/i18n/tests/test_validate.py b/i18n/tests/test_validate.py new file mode 100644 index 0000000000..bef563faea --- /dev/null +++ b/i18n/tests/test_validate.py @@ -0,0 +1,34 @@ +import os, sys, logging +from unittest import TestCase +from nose.plugins.skip import SkipTest + +from config import LOCALE_DIR +from execute import call + +def test_po_files(root=LOCALE_DIR): + """ + This is a generator. It yields all of the .po files under root, and tests each one. + """ + log = logging.getLogger(__name__) + logging.basicConfig(stream=sys.stdout, level=logging.INFO) + + for (dirpath, dirnames, filenames) in os.walk(root): + for name in filenames: + (base, ext) = os.path.splitext(name) + if ext.lower() == '.po': + yield validate_po_file, os.path.join(dirpath, name), log + + +def validate_po_file(filename, log): + """ + Call GNU msgfmt -c on each .po file to validate its format. + Any errors caught by msgfmt are logged to log. + """ + # Skip this test for now because it's very noisy + raise SkipTest() + # Use relative paths to make output less noisy. + rfile = os.path.relpath(filename, LOCALE_DIR) + (out, err) = call(['msgfmt','-c', rfile], working_directory=LOCALE_DIR) + if err != '': + log.warn('\n'+err) + diff --git a/i18n/transifex.py b/i18n/transifex.py new file mode 100755 index 0000000000..ac203f3eea --- /dev/null +++ b/i18n/transifex.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python + +import os, sys +from polib import pofile +from config import CONFIGURATION +from extract import SOURCE_WARN +from execute import execute + +TRANSIFEX_HEADER = 'Translations in this file have been downloaded from %s' +TRANSIFEX_URL = 'https://www.transifex.com/projects/p/edx-studio/' + +def push(): + execute('tx push -s') + +def pull(): + for locale in CONFIGURATION.locales: + if locale != CONFIGURATION.source_locale: + execute('tx pull -l %s' % locale) + clean_translated_locales() + + +def clean_translated_locales(): + """ + Strips out the warning from all translated po files + about being an English source file. + """ + for locale in CONFIGURATION.locales: + if locale != CONFIGURATION.source_locale: + clean_locale(locale) + +def clean_locale(locale): + """ + Strips out the warning from all of a locale's translated po files + about being an English source file. + Iterates over machine-generated files. + """ + dirname = CONFIGURATION.get_messages_dir(locale) + for filename in ('django-partial.po', 'djangojs.po', 'mako.po'): + clean_file(dirname.joinpath(filename)) + +def clean_file(file): + """ + Strips out the warning from a translated po file about being an English source file. + Replaces warning with a note about coming from Transifex. + """ + po = pofile(file) + if po.header.find(SOURCE_WARN) != -1: + new_header = get_new_header(po) + new = po.header.replace(SOURCE_WARN, new_header) + po.header = new + po.save() + +def get_new_header(po): + team = po.metadata.get('Language-Team', None) + if not team: + return TRANSIFEX_HEADER % TRANSIFEX_URL + else: + return TRANSIFEX_HEADER % team + +if __name__ == '__main__': + if len(sys.argv)<2: + raise Exception("missing argument: push or pull") + arg = sys.argv[1] + if arg == 'push': + push() + elif arg == 'pull': + pull() + else: + raise Exception("unknown argument: (%s)" % arg) + diff --git a/install.txt b/install.txt deleted file mode 100644 index 801036af6b..0000000000 --- a/install.txt +++ /dev/null @@ -1,74 +0,0 @@ -This document describes how to set up the MITx development environment -for both Linux (Ubuntu) and MacOS (OSX Lion). - -There is also a script "create-dev-env.sh" that automates these steps. - -1) Make an mitx_all directory and clone the repos - (download and install git and mercurial if you don't have them already) - - mkdir ~/mitx_all - cd ~/mitx_all - git clone git@github.com:MITx/mitx.git - hg clone ssh://hg-content@gp.mitx.mit.edu/data - -2) Install OSX dependencies (Mac users only) - - a) Install the brew utility if necessary - /usr/bin/ruby -e "$(curl -fsSL https://raw.github.com/mxcl/homebrew/master/Library/Contributions/install_homebrew.rb)" - - b) Install the brew package list - cat ~/mitx_all/mitx/brew-formulas.txt | xargs brew install - - c) Install python pip if necessary - sudo easy_install pip - - d) Install python virtualenv if necessary - sudo pip install virtualenv virtualenvwrapper - - e) Install coffee script - curl http://npmjs.org/install.sh | sh - npm install -g coffee-script - -3) Install Ubuntu dependencies (Linux users only) - - sudo apt-get install curl python-virtualenv build-essential python-dev gfortran liblapack-dev libfreetype6-dev libpng12-dev libxml2-dev libxslt-dev yui-compressor coffeescript - - -4) Install rvm, ruby, and libraries - - echo "export rvm_path=$HOME/mitx_all/ruby" > $HOME/.rvmrc - curl -sL get.rvm.io | bash -s stable - source ~/mitx_all/ruby/scripts/rvm - rvm install 1.9.3 - gem install bundler - cd ~/mitx_all/mitx - bundle install - -5) Install python libraries - - source ~/mitx_all/python/bin/activate - cd ~/mitx_all - pip install -r mitx/pre-requirements.txt - pip install -r mitx/requirements.txt - -6) Create log and db dirs - - mkdir ~/mitx_all/log - mkdir ~/mitx_all/db - -7) Start the dev server - - To start using Django you will need - to activate the local Python and Ruby - environment: - - $ source ~/mitx_all/ruby/scripts/rvm - $ source ~/mitx_all/python/bin/activate - - To initialize and start a local instance of Django: - - $ cd ~/mitx_all/mitx - $ django-admin.py syncdb --settings=envs.dev --pythonpath=. - $ django-admin.py migrate --settings=envs.dev --pythonpath=. - $ django-admin.py runserver --settings=envs.dev --pythonpath=. - diff --git a/jenkins/base.sh b/jenkins/base.sh deleted file mode 100644 index c7175e6e52..0000000000 --- a/jenkins/base.sh +++ /dev/null @@ -1,12 +0,0 @@ - -function github_status { - gcli status create mitx mitx $GIT_COMMIT \ - --params=$1 \ - target_url:$BUILD_URL \ - description:"Build #$BUILD_NUMBER is running" \ - -f csv -} - -function github_mark_failed_on_exit { - trap '[ $? == "0" ] || github_status state:failed' EXIT -} \ No newline at end of file diff --git a/jenkins/test.sh b/jenkins/test.sh index edcca840c8..35be3a0121 100755 --- a/jenkins/test.sh +++ b/jenkins/test.sh @@ -3,8 +3,21 @@ set -e set -x +## +## requires >= 1.3.0 of the Jenkins git plugin +## + function github_status { - gcli status create mitx mitx $GIT_COMMIT \ + if [[ ! ${GIT_URL} =~ git@github.com:([^/]+)/([^\.]+).git ]]; then + echo "Cannot parse Github org or repo from URL, using defaults." + ORG="edx" + REPO="edx-platform" + else + ORG=${BASH_REMATCH[1]} + REPO=${BASH_REMATCH[2]} + fi + + gcli status create $ORG $REPO $GIT_COMMIT \ --params=$1 \ target_url:$BUILD_URL \ description:"Build #$BUILD_NUMBER $2" \ @@ -27,17 +40,32 @@ git submodule foreach 'git reset --hard HEAD' export PYTHONIOENCODING=UTF-8 GIT_BRANCH=${GIT_BRANCH/HEAD/master} -if [ ! -d /mnt/virtualenvs/"$JOB_NAME" ]; then - mkdir -p /mnt/virtualenvs/"$JOB_NAME" - virtualenv /mnt/virtualenvs/"$JOB_NAME" + +# When running in parallel on jenkins, workspace could be suffixed by @x +# In that case, we want to use a separate virtualenv that matches up with +# workspace +# +# We need to handle both the case of /path/to/workspace +# and /path/to/workspace@2, which is why we use the following substitutions +# +# $WORKSPACE is the absolute path for the workspace +WORKSPACE_SUFFIX=$(expr "$WORKSPACE" : '.*\(@.*\)') || true + +VIRTUALENV_DIR="/mnt/virtualenvs/${JOB_NAME}${WORKSPACE_SUFFIX}" + +if [ ! -d "$VIRTUALENV_DIR" ]; then + mkdir -p "$VIRTUALENV_DIR" + virtualenv "$VIRTUALENV_DIR" fi export PIP_DOWNLOAD_CACHE=/mnt/pip-cache -source /mnt/virtualenvs/"$JOB_NAME"/bin/activate -pip install -q -r pre-requirements.txt -yes w | pip install -q -r test-requirements.txt -r requirements.txt +# Allow django liveserver tests to use a range of ports +export DJANGO_LIVE_TEST_SERVER_ADDRESS=${DJANGO_LIVE_TEST_SERVER_ADDRESS-localhost:8000-9000} +source /mnt/virtualenvs/"$JOB_NAME"/bin/activate + +rake install_prereqs rake clobber rake pep8 > pep8.log || cat pep8.log rake pylint > pylint.log || cat pylint.log @@ -45,15 +73,16 @@ rake pylint > pylint.log || cat pylint.log TESTS_FAILED=0 # Run the python unit tests -rake test_cms[false] || TESTS_FAILED=1 -rake test_lms[false] || TESTS_FAILED=1 +rake test_cms || TESTS_FAILED=1 +rake test_lms || TESTS_FAILED=1 rake test_common/lib/capa || TESTS_FAILED=1 rake test_common/lib/xmodule || TESTS_FAILED=1 -# Run the jaavascript unit tests +# Run the javascript unit tests rake phantomjs_jasmine_lms || TESTS_FAILED=1 rake phantomjs_jasmine_cms || TESTS_FAILED=1 rake phantomjs_jasmine_common/lib/xmodule || TESTS_FAILED=1 +rake phantomjs_jasmine_common/static/coffee || TESTS_FAILED=1 rake coverage:xml coverage:html diff --git a/jenkins/test_acceptance.sh b/jenkins/test_acceptance.sh new file mode 100755 index 0000000000..1d11265d08 --- /dev/null +++ b/jenkins/test_acceptance.sh @@ -0,0 +1,39 @@ +#! /bin/bash + +set -e +set -x + +git remote prune origin + +# Reset the submodule, in case it changed +git submodule foreach 'git reset --hard HEAD' + +# Set the IO encoding to UTF-8 so that askbot will start +export PYTHONIOENCODING=UTF-8 + +if [ ! -d /mnt/virtualenvs/"$JOB_NAME" ]; then + mkdir -p /mnt/virtualenvs/"$JOB_NAME" + virtualenv /mnt/virtualenvs/"$JOB_NAME" +fi + +export PIP_DOWNLOAD_CACHE=/mnt/pip-cache + +source /mnt/virtualenvs/"$JOB_NAME"/bin/activate +rake install_prereqs +rake clobber + +TESTS_FAILED=0 + +# Assumes that Xvfb has been started by upstart +# and is capturing display :1 +# The command for this is: +# /usr/bin/Xvfb :1 -screen 0 1024x268x24 +# This allows us to run Chrome without a display +export DISPLAY=:1 + +# Run the lms and cms acceptance tests +# (the -v flag turns off color in the output) +rake test_acceptance_lms["-v 3"] || TESTS_FAILED=1 +rake test_acceptance_cms["-v 3"] || TESTS_FAILED=1 + +[ $TESTS_FAILED == '0' ] diff --git a/lms/djangoapps/branding/views.py b/lms/djangoapps/branding/views.py index 9fe912e947..dd57e8d4d4 100644 --- a/lms/djangoapps/branding/views.py +++ b/lms/djangoapps/branding/views.py @@ -6,6 +6,7 @@ from django_future.csrf import ensure_csrf_cookie import student.views import branding import courseware.views +from mitxmako.shortcuts import marketing_link from util.cache import cache_if_anonymous @@ -22,6 +23,8 @@ def index(request): if settings.MITX_FEATURES.get('AUTH_USE_MIT_CERTIFICATES'): from external_auth.views import ssl_login return ssl_login(request) + if settings.MITX_FEATURES.get('ENABLE_MKTG_SITE'): + return redirect(settings.MKTG_URLS.get('ROOT')) university = branding.get_university(request.META.get('HTTP_HOST')) if university is None: @@ -34,9 +37,12 @@ def index(request): @cache_if_anonymous def courses(request): """ - Render the "find courses" page. If subdomain branding is on, this is the - university profile page, otherwise it's the edX courseware.views.courses page + Render the "find courses" page. If the marketing site is enabled, redirect + to that. Otherwise, if subdomain branding is on, this is the university + profile page. Otherwise, it's the edX courseware.views.courses page """ + if settings.MITX_FEATURES.get('ENABLE_MKTG_SITE', False): + return redirect(marketing_link('COURSES'), permanent=True) university = branding.get_university(request.META.get('HTTP_HOST')) if university is None: diff --git a/lms/djangoapps/courseware/access.py b/lms/djangoapps/courseware/access.py index 08bf49ac98..ace9c0096b 100644 --- a/lms/djangoapps/courseware/access.py +++ b/lms/djangoapps/courseware/access.py @@ -15,6 +15,7 @@ from xmodule.modulestore import Location from xmodule.x_module import XModule, XModuleDescriptor from student.models import CourseEnrollmentAllowed +from courseware.masquerade import is_masquerading_as_student DEBUG_ACCESS = False @@ -235,7 +236,7 @@ def _has_access_descriptor(user, descriptor, action, course_context=None): don't have to hit the enrollments table on every module load. """ # If start dates are off, can always load - if settings.MITX_FEATURES['DISABLE_START_DATES']: + if settings.MITX_FEATURES['DISABLE_START_DATES'] and not is_masquerading_as_student(user): debug("Allow: DISABLE_START_DATES") return True @@ -543,6 +544,10 @@ def _has_access_to_location(user, location, access_level, course_context): if user is None or (not user.is_authenticated()): debug("Deny: no user or anon user") return False + + if is_masquerading_as_student(user): + return False + if user.is_staff: debug("Allow: user.is_staff") return True diff --git a/lms/djangoapps/courseware/features/common.py b/lms/djangoapps/courseware/features/common.py index f6256adfa1..874ba0142a 100644 --- a/lms/djangoapps/courseware/features/common.py +++ b/lms/djangoapps/courseware/features/common.py @@ -1,6 +1,8 @@ #pylint: disable=C0111 #pylint: disable=W0621 +from __future__ import absolute_import + from lettuce import world, step from nose.tools import assert_equals, assert_in from lettuce.django import django_url @@ -18,7 +20,7 @@ logger = getLogger(__name__) TEST_COURSE_ORG = 'edx' TEST_COURSE_NAME = 'Test Course' -TEST_SECTION_NAME = "Problem" +TEST_SECTION_NAME = 'Test Section' @step(u'The course "([^"]*)" exists$') diff --git a/lms/djangoapps/courseware/features/homepage.feature b/lms/djangoapps/courseware/features/homepage.feature index c0c1c32f02..2c354acd49 100644 --- a/lms/djangoapps/courseware/features/homepage.feature +++ b/lms/djangoapps/courseware/features/homepage.feature @@ -5,29 +5,24 @@ Feature: Homepage for web users Scenario: User can see the "Login" button Given I visit the homepage - Then I should see a link called "Log In" + Then I should see a link called "Log in" - Scenario: User can see the "Sign up" button + Scenario: User can see the "Register Now" button Given I visit the homepage - Then I should see a link called "Sign Up" + Then I should see a link called "Register Now" Scenario Outline: User can see main parts of the page Given I visit the homepage - Then I should see a link called "" - When I click the link with the text "" - Then I should see that the path is "" + Then I should see a link with the id "" called "" Examples: - | Link | Path | - | Find Courses | /courses | - | About | /about | - | Jobs | /jobs | - | Contact | /contact | + | id | Link | + | about | About | + | jobs | Jobs | + | faq | FAQ | + | contact | Contact| + | press | Press | - Scenario: User can visit the blog - Given I visit the homepage - When I click the link with the text "Blog" - Then I should see that the url is "http://blog.edx.org/" # TODO: test according to domain or policy Scenario: User can see the partner institutions diff --git a/lms/djangoapps/courseware/features/login.feature b/lms/djangoapps/courseware/features/login.feature index 23317b4876..a1b788a7b2 100644 --- a/lms/djangoapps/courseware/features/login.feature +++ b/lms/djangoapps/courseware/features/login.feature @@ -7,7 +7,7 @@ Feature: Login in as a registered user Given I am an edX user And I am an unactivated user And I visit the homepage - When I click the link with the text "Log In" + When I click the link with the text "Log in" And I submit my credentials on the login form Then I should see the login error message "This account has not been activated" @@ -15,7 +15,7 @@ Feature: Login in as a registered user Given I am an edX user And I am an activated user And I visit the homepage - When I click the link with the text "Log In" + When I click the link with the text "Log in" And I submit my credentials on the login form Then I should be on the dashboard page @@ -23,5 +23,5 @@ Feature: Login in as a registered user Given I am logged in When I click the dropdown arrow And I click the link with the text "Log Out" - Then I should see a link with the text "Log In" + Then I should see a link with the text "Log in" And I should see that the path is "/" diff --git a/lms/djangoapps/courseware/features/login.py b/lms/djangoapps/courseware/features/login.py index bc90ea301c..857b70fa5d 100644 --- a/lms/djangoapps/courseware/features/login.py +++ b/lms/djangoapps/courseware/features/login.py @@ -19,13 +19,13 @@ def i_am_an_activated_user(step): def i_submit_my_credentials_on_the_login_form(step): fill_in_the_login_form('email', 'robot@edx.org') fill_in_the_login_form('password', 'test') - login_form = world.browser.find_by_css('form#login_form') - login_form.find_by_value('Access My Courses').click() + login_form = world.browser.find_by_css('form#login-form') + login_form.find_by_name('submit').click() @step(u'I should see the login error message "([^"]*)"$') def i_should_see_the_login_error_message(step, msg): - login_error_div = world.browser.find_by_css('form#login_form #login_error') + login_error_div = world.browser.find_by_css('.submission-error.is-shown') assert (msg in login_error_div.text) @@ -49,6 +49,6 @@ def user_is_an_activated_user(uname): def fill_in_the_login_form(field, value): - login_form = world.browser.find_by_css('form#login_form') + login_form = world.browser.find_by_css('form#login-form') form_field = login_form.find_by_name(field) form_field.fill(value) diff --git a/lms/djangoapps/courseware/features/problems.feature b/lms/djangoapps/courseware/features/problems.feature index dc8495af60..266ffa3680 100644 --- a/lms/djangoapps/courseware/features/problems.feature +++ b/lms/djangoapps/courseware/features/problems.feature @@ -15,6 +15,7 @@ Feature: Answer problems | drop down | | multiple choice | | checkbox | + | radio | | string | | numerical | | formula | @@ -33,6 +34,7 @@ Feature: Answer problems | drop down | | multiple choice | | checkbox | + | radio | | string | | numerical | | formula | @@ -50,6 +52,7 @@ Feature: Answer problems | drop down | | multiple choice | | checkbox | + | radio | | string | | numerical | | formula | @@ -71,6 +74,8 @@ Feature: Answer problems | multiple choice | incorrect | | checkbox | correct | | checkbox | incorrect | + | radio | correct | + | radio | incorrect | | string | correct | | string | incorrect | | numerical | correct | diff --git a/lms/djangoapps/courseware/features/problems.py b/lms/djangoapps/courseware/features/problems.py index b25d606c4e..3d538d7ae1 100644 --- a/lms/djangoapps/courseware/features/problems.py +++ b/lms/djangoapps/courseware/features/problems.py @@ -42,7 +42,13 @@ PROBLEM_FACTORY_DICT = { 'choice_type': 'checkbox', 'choices': [True, False, True, False, False], 'choice_names': ['Choice 1', 'Choice 2', 'Choice 3', 'Choice 4']}}, - + 'radio': { + 'factory': ChoiceResponseXMLFactory(), + 'kwargs': { + 'question_text': 'The correct answer is Choice 3', + 'choice_type': 'radio', + 'choices': [False, False, True, False], + 'choice_names': ['Choice 1', 'Choice 2', 'Choice 3', 'Choice 4']}}, 'string': { 'factory': StringResponseXMLFactory(), 'kwargs': { @@ -174,6 +180,12 @@ def answer_problem(step, problem_type, correctness): else: inputfield('checkbox', choice='choice_3').check() + elif problem_type == 'radio': + if correctness == 'correct': + inputfield('radio', choice='choice_2').check() + else: + inputfield('radio', choice='choice_1').check() + elif problem_type == 'string': textvalue = 'correct string' if correctness == 'correct' \ else 'incorrect' @@ -252,6 +264,14 @@ def assert_problem_has_answer(step, problem_type, answer_class): else: assert_checked('checkbox', []) + elif problem_type == "radio": + if answer_class == 'correct': + assert_checked('radio', ['choice_2']) + elif answer_class == 'incorrect': + assert_checked('radio', ['choice_1']) + else: + assert_checked('radio', []) + elif problem_type == 'string': if answer_class == 'blank': expected = '' @@ -298,6 +318,7 @@ CORRECTNESS_SELECTORS = { 'correct': {'drop down': ['span.correct'], 'multiple choice': ['label.choicegroup_correct'], 'checkbox': ['span.correct'], + 'radio': ['label.choicegroup_correct'], 'string': ['div.correct'], 'numerical': ['div.correct'], 'formula': ['div.correct'], @@ -308,6 +329,8 @@ CORRECTNESS_SELECTORS = { 'multiple choice': ['label.choicegroup_incorrect', 'span.incorrect'], 'checkbox': ['span.incorrect'], + 'radio': ['label.choicegroup_incorrect', + 'span.incorrect'], 'string': ['div.incorrect'], 'numerical': ['div.incorrect'], 'formula': ['div.incorrect'], @@ -317,6 +340,7 @@ CORRECTNESS_SELECTORS = { 'unanswered': {'drop down': ['span.unanswered'], 'multiple choice': ['span.unanswered'], 'checkbox': ['span.unanswered'], + 'radio': ['span.unanswered'], 'string': ['div.unanswered'], 'numerical': ['div.unanswered'], 'formula': ['div.unanswered'], diff --git a/lms/djangoapps/courseware/features/registration.feature b/lms/djangoapps/courseware/features/registration.feature index 5933f860bb..6c850a0b43 100644 --- a/lms/djangoapps/courseware/features/registration.feature +++ b/lms/djangoapps/courseware/features/registration.feature @@ -13,6 +13,8 @@ Feature: Register for a course Scenario: I can unregister for a course Given I am registered for the course "6.002x" And I visit the dashboard - When I click the link with the text "Unregister" - And I press the "Unregister" button in the Unenroll dialog - Then I should see "Looks like you haven't registered for any courses yet." somewhere in the page + Then I should see the course numbered "6.002x" in my dashboard + When I unregister for the course numbered "6.002x" + Then I should be on the dashboard page + And I should see "Looks like you haven't registered for any courses yet." somewhere in the page + And I should NOT see the course numbered "6.002x" in my dashboard diff --git a/lms/djangoapps/courseware/features/registration.py b/lms/djangoapps/courseware/features/registration.py index 72bde65f99..dd2fcb0825 100644 --- a/lms/djangoapps/courseware/features/registration.py +++ b/lms/djangoapps/courseware/features/registration.py @@ -25,8 +25,15 @@ def i_should_see_that_course_in_my_dashboard(step, course): assert world.is_css_present(course_link_css) -@step(u'I press the "([^"]*)" button in the Unenroll dialog') -def i_press_the_button_in_the_unenroll_dialog(step, value): - button_css = 'section#unenroll-modal input[value="%s"]' % value +@step(u'I should NOT see the course numbered "([^"]*)" in my dashboard$') +def i_should_not_see_that_course_in_my_dashboard(step, course): + course_link_css = 'section.my-courses a[href*="%s"]' % course + assert not world.is_css_present(course_link_css) + + +@step(u'I unregister for the course numbered "([^"]*)"') +def i_unregister_for_that_course(step, course): + unregister_css = 'section.info a[href*="#unenroll-modal"][data-course-number*="%s"]' % course + world.css_click(unregister_css) + button_css = 'section#unenroll-modal input[value="Unregister"]' world.css_click(button_css) - assert world.is_css_present('section.container.dashboard') diff --git a/lms/djangoapps/courseware/features/signup.feature b/lms/djangoapps/courseware/features/signup.feature index b28a6819a1..cfc8b6e924 100644 --- a/lms/djangoapps/courseware/features/signup.feature +++ b/lms/djangoapps/courseware/features/signup.feature @@ -5,12 +5,12 @@ Feature: Sign in Scenario: Sign up from the homepage Given I visit the homepage - When I click the link with the text "Sign Up" + When I click the link with the text "Register Now" And I fill in "email" on the registration form with "robot2@edx.org" And I fill in "password" on the registration form with "test" And I fill in "username" on the registration form with "robot2" And I fill in "name" on the registration form with "Robot Two" And I check the checkbox named "terms_of_service" And I check the checkbox named "honor_code" - And I press the "Create My Account" button on the registration form + And I submit the registration form Then I should see "THANKS FOR REGISTERING!" in the dashboard banner diff --git a/lms/djangoapps/courseware/features/signup.py b/lms/djangoapps/courseware/features/signup.py index 5ba385ef54..3dc34d5af8 100644 --- a/lms/djangoapps/courseware/features/signup.py +++ b/lms/djangoapps/courseware/features/signup.py @@ -3,17 +3,18 @@ from lettuce import world, step + @step('I fill in "([^"]*)" on the registration form with "([^"]*)"$') def when_i_fill_in_field_on_the_registration_form_with_value(step, field, value): - register_form = world.browser.find_by_css('form#register_form') + register_form = world.browser.find_by_css('form#register-form') form_field = register_form.find_by_name(field) form_field.fill(value) -@step('I press the "([^"]*)" button on the registration form$') -def i_press_the_button_on_the_registration_form(step, button): - register_form = world.browser.find_by_css('form#register_form') - register_form.find_by_value(button).click() +@step('I submit the registration form$') +def i_press_the_button_on_the_registration_form(step): + register_form = world.browser.find_by_css('form#register-form') + register_form.find_by_name('submit').click() @step('I check the checkbox named "([^"]*)"$') diff --git a/lms/djangoapps/courseware/features/video.feature b/lms/djangoapps/courseware/features/video.feature new file mode 100644 index 0000000000..c4d96f93f7 --- /dev/null +++ b/lms/djangoapps/courseware/features/video.feature @@ -0,0 +1,6 @@ +Feature: Video component + As a student, I want to view course videos in LMS. + + Scenario: Autoplay is enabled in LMS + Given the course has a Video component + Then when I view the video it has autoplay enabled diff --git a/lms/djangoapps/courseware/features/video.py b/lms/djangoapps/courseware/features/video.py new file mode 100644 index 0000000000..9930489d4b --- /dev/null +++ b/lms/djangoapps/courseware/features/video.py @@ -0,0 +1,34 @@ +#pylint: disable=C0111 + +from lettuce import world, step +from lettuce.django import django_url +from common import TEST_COURSE_NAME, TEST_SECTION_NAME, i_am_registered_for_the_course, section_location + +############### ACTIONS #################### + + +@step('when I view the video it has autoplay enabled') +def does_autoplay(step): + assert(world.css_find('.video')[0]['data-autoplay'] == 'True') + + +@step('the course has a Video component') +def view_video(step): + coursename = TEST_COURSE_NAME.replace(' ', '_') + i_am_registered_for_the_course(step, coursename) + + # Make sure we have a video + add_video_to_course(coursename) + chapter_name = TEST_SECTION_NAME.replace(" ", "_") + section_name = chapter_name + url = django_url('/courses/edx/Test_Course/Test_Course/courseware/%s/%s' % + (chapter_name, section_name)) + + world.browser.visit(url) + + +def add_video_to_course(course): + template_name = 'i4x://edx/templates/video/default' + world.ItemFactory.create(parent_location=section_location(course), + template=template_name, + display_name='Video') diff --git a/lms/djangoapps/courseware/masquerade.py b/lms/djangoapps/courseware/masquerade.py new file mode 100644 index 0000000000..27135f727e --- /dev/null +++ b/lms/djangoapps/courseware/masquerade.py @@ -0,0 +1,65 @@ +''' +---------------------------------------- Masequerade ---------------------------------------- +Allow course staff to see a student or staff view of courseware. +Which kind of view has been selected is stored in the session state. +''' + +import json +import logging + +from django.http import HttpResponse +from django.conf import settings + +log = logging.getLogger(__name__) + +MASQ_KEY = 'masquerade_identity' + + +def handle_ajax(request, marg): + ''' + Handle ajax call from "staff view" / "student view" toggle button + ''' + if marg == 'toggle': + status = request.session.get(MASQ_KEY, '') + if status is None or status in ['', 'staff']: + status = 'student' + else: + status = 'staff' + request.session[MASQ_KEY] = status + return HttpResponse(json.dumps({'status': status})) + + +def setup_masquerade(request, staff_access=False): + ''' + Setup masquerade identity (allows staff to view courseware as either staff or student) + + Uses request.session[MASQ_KEY] to store status of masquerading. + Adds masquerade status to request.user, if masquerading active. + Return string version of status of view (either 'staff' or 'student') + ''' + if request.user is None: + return None + + if not settings.MITX_FEATURES.get('ENABLE_MASQUERADE', False): + return None + + if not staff_access: # can masquerade only if user has staff access to course + return None + + usertype = request.session.get(MASQ_KEY, '') + if usertype is None or not usertype: + request.session[MASQ_KEY] = 'staff' + usertype = 'staff' + + if usertype == 'student': + request.user.masquerade_as_student = True + + return usertype + + +def is_masquerading_as_student(user): + ''' + Return True if user is masquerading as a student, False otherwise + ''' + masq = getattr(user, 'masquerade_as_student', False) + return masq==True diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 0228526cba..284b746249 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -1,6 +1,7 @@ import json import logging import pyparsing +import re import sys import static_replace @@ -8,6 +9,7 @@ from functools import partial from django.conf import settings from django.contrib.auth.models import User +from django.core.cache import cache from django.core.exceptions import PermissionDenied from django.core.urlresolvers import reverse from django.http import Http404 @@ -17,6 +19,7 @@ from django.views.decorators.csrf import csrf_exempt from requests.auth import HTTPBasicAuth from capa.xqueue_interface import XQueueInterface +from courseware.masquerade import setup_masquerade from courseware.access import has_access from mitxmako.shortcuts import render_to_string from .models import StudentModule @@ -164,6 +167,10 @@ def get_module_for_descriptor(user, request, descriptor, model_data_cache, cours Actually implement get_module. See docstring there for details. """ + # allow course staff to masquerade as student + if has_access(user, descriptor, 'staff', course_id): + setup_masquerade(request, True) + # Short circuit--if the user shouldn't have access, bail without doing any work if not has_access(user, descriptor, 'load', course_id): return None @@ -207,22 +214,27 @@ def get_module_for_descriptor(user, request, descriptor, model_data_cache, cours #This is a hacky way to pass settings to the combined open ended xmodule #It needs an S3 interface to upload images to S3 #It needs the open ended grading interface in order to get peer grading to be done - #TODO: refactor these settings into module-specific settings when possible. #this first checks to see if the descriptor is the correct one, and only sends settings if it is - is_descriptor_combined_open_ended = (descriptor.__class__.__name__ == 'CombinedOpenEndedDescriptor') - is_descriptor_peer_grading = (descriptor.__class__.__name__ == 'PeerGradingDescriptor') + + #Get descriptor metadata fields indicating needs for various settings + needs_open_ended_interface = getattr(descriptor, "needs_open_ended_interface", False) + needs_s3_interface = getattr(descriptor, "needs_s3_interface", False) + + #Initialize interfaces to None open_ended_grading_interface = None s3_interface = None - if is_descriptor_combined_open_ended or is_descriptor_peer_grading: + + #Create interfaces if needed + if needs_open_ended_interface: open_ended_grading_interface = settings.OPEN_ENDED_GRADING_INTERFACE open_ended_grading_interface['mock_peer_grading'] = settings.MOCK_PEER_GRADING open_ended_grading_interface['mock_staff_grading'] = settings.MOCK_STAFF_GRADING - if is_descriptor_combined_open_ended: - s3_interface = { - 'access_key' : getattr(settings,'AWS_ACCESS_KEY_ID',''), - 'secret_access_key' : getattr(settings,'AWS_SECRET_ACCESS_KEY',''), - 'storage_bucket_name' : getattr(settings,'AWS_STORAGE_BUCKET_NAME','openended') - } + if needs_s3_interface: + s3_interface = { + 'access_key': getattr(settings, 'AWS_ACCESS_KEY_ID', ''), + 'secret_access_key': getattr(settings, 'AWS_SECRET_ACCESS_KEY', ''), + 'storage_bucket_name': getattr(settings, 'AWS_STORAGE_BUCKET_NAME', 'openended') + } def inner_get_module(descriptor): """ @@ -268,6 +280,14 @@ def get_module_for_descriptor(user, request, descriptor, model_data_cache, cours statsd.increment("lms.courseware.question_answered", tags=tags) + def can_execute_unsafe_code(): + # To decide if we can run unsafe code, we check the course id against + # a list of regexes configured on the server. + for regex in settings.COURSES_WITH_UNSAFE_CODE: + if re.match(regex, course_id): + return True + return False + # TODO (cpennington): When modules are shared between courses, the static # prefix is going to have to be specific to the module, not the directory # that the xml was loaded from @@ -294,6 +314,8 @@ def get_module_for_descriptor(user, request, descriptor, model_data_cache, cours course_id=course_id, open_ended_grading_interface=open_ended_grading_interface, s3_interface=s3_interface, + cache=cache, + can_execute_unsafe_code=can_execute_unsafe_code, ) # pass position specified in URL to module through ModuleSystem system.set('position', position) @@ -397,6 +419,11 @@ def modx_dispatch(request, dispatch, location, course_id): through the part before the first '?'. - location -- the module location. Used to look up the XModule instance - course_id -- defines the course context for this request. + + Raises PermissionDenied if the user is not logged in. Raises Http404 if + the location and course_id do not identify a valid module, the module is + not accessible by the user, or the module raises NotFoundError. If the + module raises any other error, it will escape this function. ''' # ''' (fix emacs broken parsing) @@ -425,8 +452,19 @@ def modx_dispatch(request, dispatch, location, course_id): return HttpResponse(json.dumps({'success': file_too_big_msg})) p[fileinput_id] = inputfiles + try: + descriptor = modulestore().get_instance(course_id, location) + except ItemNotFoundError: + log.warn( + "Invalid location for course id {course_id}: {location}".format( + course_id=course_id, + location=location + ) + ) + raise Http404 + model_data_cache = ModelDataCache.cache_for_descriptor_descendents(course_id, - request.user, modulestore().get_instance(course_id, location)) + request.user, descriptor) instance = get_module(request.user, request, location, model_data_cache, course_id, grade_bucket_type='ajax') if instance is None: diff --git a/lms/djangoapps/courseware/tabs.py b/lms/djangoapps/courseware/tabs.py index 9f9a4e3e96..42b1c05743 100644 --- a/lms/djangoapps/courseware/tabs.py +++ b/lms/djangoapps/courseware/tabs.py @@ -185,6 +185,11 @@ def _combined_open_ended_grading(tab, user, course, active_page): return tab return [] +def _notes_tab(tab, user, course, active_page): + if user.is_authenticated() and settings.MITX_FEATURES.get('ENABLE_STUDENT_NOTES'): + link = reverse('notes', args=[course.id]) + return [CourseTab(tab['name'], link, active_page == 'notes')] + return [] #### Validators @@ -227,6 +232,7 @@ VALID_TAB_TYPES = { 'peer_grading': TabImpl(null_validator, _peer_grading), 'staff_grading': TabImpl(null_validator, _staff_grading), 'open_ended': TabImpl(null_validator, _combined_open_ended_grading), + 'notes': TabImpl(null_validator, _notes_tab) } @@ -294,6 +300,27 @@ def get_course_tabs(user, course, active_page): return tabs +def get_discussion_link(course): + """ + Return the URL for the discussion tab for the given `course`. + + If they have a discussion link specified, use that even if we disable + discussions. Disabling discsussions is mostly a server safety feature at + this point, and we don't need to worry about external sites. Otherwise, + if the course has a discussion tab or uses the default tabs, return the + discussion view URL. Otherwise, return None to indicate the lack of a + discussion tab. + """ + if course.discussion_link: + return course.discussion_link + elif not settings.MITX_FEATURES.get('ENABLE_DISCUSSION_SERVICE'): + return None + elif hasattr(course, 'tabs') and course.tabs and not any([tab['type'] == 'discussion' for tab in course.tabs]): + return None + else: + return reverse('django_comment_client.forum.views.forum_form_discussion', args=[course.id]) + + def get_default_tabs(user, course, active_page): # When calling the various _tab methods, can omit the 'type':'blah' from the @@ -308,15 +335,9 @@ def get_default_tabs(user, course, active_page): tabs.extend(_textbooks({}, user, course, active_page)) - ## If they have a discussion link specified, use that even if we feature - ## flag discussions off. Disabling that is mostly a server safety feature - ## at this point, and we don't need to worry about external sites. - if course.discussion_link: - tabs.append(CourseTab('Discussion', course.discussion_link, active_page == 'discussion')) - elif settings.MITX_FEATURES.get('ENABLE_DISCUSSION_SERVICE'): - link = reverse('django_comment_client.forum.views.forum_form_discussion', - args=[course.id]) - tabs.append(CourseTab('Discussion', link, active_page == 'discussion')) + discussion_link = get_discussion_link(course) + if discussion_link: + tabs.append(CourseTab('Discussion', discussion_link, active_page == 'discussion')) tabs.extend(_wiki({'name': 'Wiki', 'type': 'wiki'}, user, course, active_page)) diff --git a/lms/djangoapps/courseware/tests/factories.py b/lms/djangoapps/courseware/tests/factories.py index a84b2b8475..af33ba1211 100644 --- a/lms/djangoapps/courseware/tests/factories.py +++ b/lms/djangoapps/courseware/tests/factories.py @@ -1,49 +1,85 @@ -import factory -from student.models import (User, UserProfile, Registration, - CourseEnrollmentAllowed) -from django.contrib.auth.models import Group from datetime import datetime -import uuid +import json +from functools import partial + +from factory import DjangoModelFactory, SubFactory +from student.tests.factories import UserFactory as StudentUserFactory +from student.tests.factories import GroupFactory as StudentGroupFactory +from student.tests.factories import UserProfileFactory as StudentUserProfileFactory +from student.tests.factories import CourseEnrollmentAllowedFactory as StudentCourseEnrollmentAllowedFactory +from student.tests.factories import RegistrationFactory as StudentRegistrationFactory +from courseware.models import StudentModule, XModuleContentField, XModuleSettingsField +from courseware.models import XModuleStudentInfoField, XModuleStudentPrefsField + +from xmodule.modulestore import Location + +location = partial(Location, 'i4x', 'edX', 'test_course', 'problem') -class UserProfileFactory(factory.Factory): - FACTORY_FOR = UserProfile - - user = None +class UserProfileFactory(StudentUserProfileFactory): name = 'Robot Studio' courseware = 'course.xml' -class RegistrationFactory(factory.Factory): - FACTORY_FOR = Registration - - user = None - activation_key = uuid.uuid4().hex +class RegistrationFactory(StudentRegistrationFactory): + pass -class UserFactory(factory.Factory): - FACTORY_FOR = User - - username = 'robot' +class UserFactory(StudentUserFactory): email = 'robot@edx.org' - password = 'test' - first_name = 'Robot' last_name = 'Tester' - is_staff = False - is_active = True - is_superuser = False last_login = datetime.now() date_joined = datetime.now() -class GroupFactory(factory.Factory): - FACTORY_FOR = Group - +class GroupFactory(StudentGroupFactory): name = 'test_group' -class CourseEnrollmentAllowedFactory(factory.Factory): - FACTORY_FOR = CourseEnrollmentAllowed +class CourseEnrollmentAllowedFactory(StudentCourseEnrollmentAllowedFactory): + pass - email = 'test@edx.org' - course_id = 'edX/test/2012_Fall' + +class StudentModuleFactory(DjangoModelFactory): + FACTORY_FOR = StudentModule + + module_type = "problem" + student = SubFactory(UserFactory) + course_id = "MITx/999/Robot_Super_Course" + state = None + grade = None + max_grade = None + done = 'na' + + +class ContentFactory(DjangoModelFactory): + FACTORY_FOR = XModuleContentField + + field_name = 'existing_field' + value = json.dumps('old_value') + definition_id = location('def_id').url() + + +class SettingsFactory(DjangoModelFactory): + FACTORY_FOR = XModuleSettingsField + + field_name = 'existing_field' + value = json.dumps('old_value') + usage_id = '%s-%s' % ('edX/test_course/test', location('def_id').url()) + + +class StudentPrefsFactory(DjangoModelFactory): + FACTORY_FOR = XModuleStudentPrefsField + + field_name = 'existing_field' + value = json.dumps('old_value') + student = SubFactory(UserFactory) + module_type = 'problem' + + +class StudentInfoFactory(DjangoModelFactory): + FACTORY_FOR = XModuleStudentInfoField + + field_name = 'existing_field' + value = json.dumps('old_value') + student = SubFactory(UserFactory) diff --git a/lms/djangoapps/courseware/tests/load_tests/README.md b/lms/djangoapps/courseware/tests/load_tests/README.md new file mode 100644 index 0000000000..09d8797947 --- /dev/null +++ b/lms/djangoapps/courseware/tests/load_tests/README.md @@ -0,0 +1,4 @@ +# Load Testing + +Scripts for load testing the courseware app, +mostly using [multimechanize](http://testutils.org/multi-mechanize/) diff --git a/lms/djangoapps/courseware/tests/load_tests/custom_response/README.md b/lms/djangoapps/courseware/tests/load_tests/custom_response/README.md new file mode 100644 index 0000000000..e3fae8c817 --- /dev/null +++ b/lms/djangoapps/courseware/tests/load_tests/custom_response/README.md @@ -0,0 +1,51 @@ +# Custom Response Load Test + +## Optional Installations + +* [memcached](http://pypi.python.org/pypi/python-memcached/): Install this +and make sure it is running, or the Capa problem will not cache results. + +* [AppArmor](http://wiki.apparmor.net): Follow the instructions in +`common/lib/codejail/README` to set up the Python sandbox environment. +If you do not set up the sandbox, the tests will still execute code in the CustomResponse, +so you can still run the tests. + +* [matplotlib](http://matplotlib.org): Multi-mechanize uses this to create graphs. + + +## Running the Tests + +This test simulates student submissions for a custom response problem. + +First, clear the cache: + + /etc/init.d/memcached restart + +Then, run the test: + + multimech-run custom_response + +You can configure the parameters in `customresponse/config.cfg`, +and you can change the CustomResponse script and student submissions +in `customresponse/test_scripts/v_user.py`. + +## Components Under Test + +Components under test: + +* Python sandbox (see `common/lib/codejail`), which uses `AppArmor` +* Caching (see `common/lib/capa/capa/safe_exec/`), which uses `memcache` in production + +Components NOT under test: + +* Django views +* `XModule` +* gunicorn + +This allows us to avoid creating courses in mongo, logging in, using CSRF tokens, +and other inconveniences. Instead, we create a capa problem (from the capa package), +pass it Django's memcache backend, and pass the problem student submissions. + +Even though the test uses `capa.capa_problem.LoncapaProblem` directly, +the `capa` should not depend on Django. For this reason, we put the +test in the `courseware` Django app. diff --git a/lms/djangoapps/courseware/tests/load_tests/custom_response/config.cfg b/lms/djangoapps/courseware/tests/load_tests/custom_response/config.cfg new file mode 100644 index 0000000000..c75f02a669 --- /dev/null +++ b/lms/djangoapps/courseware/tests/load_tests/custom_response/config.cfg @@ -0,0 +1,22 @@ + +[global] +run_time = 240 +rampup = 30 +results_ts_interval = 10 +progress_bar = on +console_logging = off +xml_report = off + + +[user_group-1] +threads = 10 +script = v_user.py + +[user_group-2] +threads = 10 +script = v_user.py + +[user_group-3] +threads = 10 +script = v_user.py + diff --git a/lms/djangoapps/courseware/tests/load_tests/custom_response/test_scripts/v_user.py b/lms/djangoapps/courseware/tests/load_tests/custom_response/test_scripts/v_user.py new file mode 100644 index 0000000000..9bfc39e55b --- /dev/null +++ b/lms/djangoapps/courseware/tests/load_tests/custom_response/test_scripts/v_user.py @@ -0,0 +1,115 @@ +""" User script for load testing CustomResponse """ + +from capa.tests.response_xml_factory import CustomResponseXMLFactory +import capa.capa_problem as lcp +from xmodule.x_module import ModuleSystem +import mock +import fs.osfs +import random +import textwrap + +# Use memcache running locally +CACHE_SETTINGS = { + 'default': { + 'BACKEND': 'django.core.cache.backends.memcached.MemcachedCache', + 'LOCATION': '127.0.0.1:11211' + }, +} + +# Configure settings so Django will let us import its cache wrapper +# Caching is the only part of Django being tested +from django.conf import settings +settings.configure(CACHES=CACHE_SETTINGS) + +from django.core.cache import cache + +# Script to install as the checker for the CustomResponse +TEST_SCRIPT = textwrap.dedent(""" + def check_func(expect, answer_given): + return {'ok': answer_given == expect, 'msg': 'Message text'} +""") + +# Submissions submitted by the student +TEST_SUBMISSIONS = [random.randint(-100, 100) for i in range(100)] + +class TestContext(object): + """ One-time set up for the test that is shared across transactions. + Uses a Singleton design pattern.""" + + SINGLETON = None + NUM_UNIQUE_SEEDS = 20 + + @classmethod + def singleton(cls): + """ Return the singleton, creating one if it does not already exist.""" + + # If we haven't created the singleton yet, create it now + if cls.SINGLETON is None: + + # Create a mock ModuleSystem, installing our cache + system = mock.MagicMock(ModuleSystem) + system.render_template = lambda template, context: "
%s
" % template + system.cache = cache + system.filestore = mock.MagicMock(fs.osfs.OSFS) + system.filestore.root_path = "" + system.DEBUG = True + + # Create a custom response problem + xml_factory = CustomResponseXMLFactory() + xml = xml_factory.build_xml(script=TEST_SCRIPT, cfn="check_func", expect="42") + + # Create and store the context + cls.SINGLETON = cls(system, xml) + + else: + pass + + # Return the singleton + return cls.SINGLETON + + def __init__(self, system, xml): + """ Store context needed for the test across transactions """ + self.system = system + self.xml = xml + + # Construct a small pool of unique seeds + # To keep our implementation in line with the one capa actually uses, + # construct the problems, then use the seeds they generate + self.seeds = [lcp.LoncapaProblem(self.xml, 'problem_id', system=self.system).seed + for i in range(self.NUM_UNIQUE_SEEDS)] + + def random_seed(self): + """ Return one of a small number of unique random seeds """ + return random.choice(self.seeds) + + def student_submission(self): + """ Return one of a small number of student submissions """ + return random.choice(TEST_SUBMISSIONS) + +class Transaction(object): + """ User script that submits a response to a CustomResponse problem """ + + def __init__(self): + """ Create the problem """ + + # Get the context (re-used across transactions) + self.context = TestContext.singleton() + + # Create a new custom response problem + # using one of a small number of unique seeds + # We're assuming that the capa module is limiting the number + # of seeds (currently not the case for certain settings) + self.problem = lcp.LoncapaProblem(self.context.xml, + '1', + state=None, + seed=self.context.random_seed(), + system=self.context.system) + + def run(self): + """ Submit a response to the CustomResponse problem """ + answers = {'1_2_1': self.context.student_submission()} + self.problem.grade_answers(answers) + +if __name__ == '__main__': + trans = Transaction() + trans.run() diff --git a/lms/djangoapps/courseware/tests/test_login.py b/lms/djangoapps/courseware/tests/test_login.py index dda58a4462..9f1cd23b27 100644 --- a/lms/djangoapps/courseware/tests/test_login.py +++ b/lms/djangoapps/courseware/tests/test_login.py @@ -1,27 +1,29 @@ +''' +Tests for student activation and login +''' from django.test import TestCase from django.test.client import Client from django.core.urlresolvers import reverse -from django.contrib.auth.models import User -from student.models import Registration, UserProfile +from courseware.tests.factories import UserFactory, RegistrationFactory, UserProfileFactory import json + class LoginTest(TestCase): ''' Test student.views.login_user() view ''' def setUp(self): - # Create one user and save it to the database - self.user = User.objects.create_user('test', 'test@edx.org', 'test_password') - self.user.is_active = True + self.user = UserFactory.build(username='test', email='test@edx.org') + self.user.set_password('test_password') self.user.save() # Create a registration for the user - Registration().register(self.user) + RegistrationFactory(user=self.user) # Create a profile for the user - UserProfile(user=self.user).save() + UserProfileFactory(user=self.user) # Create the test client self.client = Client() @@ -42,19 +44,17 @@ class LoginTest(TestCase): response = self._login_response(unicode_email, 'test_password') self._assert_response(response, success=True) - def test_login_fail_no_user_exists(self): response = self._login_response('not_a_user@edx.org', 'test_password') - self._assert_response(response, success=False, - value='Email or password is incorrect') + self._assert_response(response, success=False, + value='Email or password is incorrect') def test_login_fail_wrong_password(self): response = self._login_response('test@edx.org', 'wrong_password') - self._assert_response(response, success=False, - value='Email or password is incorrect') + self._assert_response(response, success=False, + value='Email or password is incorrect') def test_login_not_activated(self): - # De-activate the user self.user.is_active = False self.user.save() @@ -62,8 +62,7 @@ class LoginTest(TestCase): # Should now be unable to login response = self._login_response('test@edx.org', 'test_password') self._assert_response(response, success=False, - value="This account has not been activated") - + value="This account has not been activated") def test_login_unicode_email(self): unicode_email = u'test@edx.org' + unichr(40960) @@ -76,6 +75,7 @@ class LoginTest(TestCase): self._assert_response(response, success=False) def _login_response(self, email, password): + ''' Post the login info ''' post_params = {'email': email, 'password': password} return self.client.post(self.url, post_params) @@ -95,13 +95,13 @@ class LoginTest(TestCase): try: response_dict = json.loads(response.content) except ValueError: - self.fail("Could not parse response content as JSON: %s" - % str(response.content)) + self.fail("Could not parse response content as JSON: %s" + % str(response.content)) if success is not None: self.assertEqual(response_dict['success'], success) if value is not None: - msg = ("'%s' did not contain '%s'" % - (str(response_dict['value']), str(value))) + msg = ("'%s' did not contain '%s'" % + (str(response_dict['value']), str(value))) self.assertTrue(value in response_dict['value'], msg) diff --git a/lms/djangoapps/courseware/tests/test_masquerade.py b/lms/djangoapps/courseware/tests/test_masquerade.py new file mode 100644 index 0000000000..11cdc4c1f9 --- /dev/null +++ b/lms/djangoapps/courseware/tests/test_masquerade.py @@ -0,0 +1,120 @@ +""" +Unit tests for masquerade + +Based on (and depends on) unit tests for courseware. + +Notes for running by hand: + +django-admin.py test --settings=lms.envs.test --pythonpath=. lms/djangoapps/courseware +""" + +from django.test.utils import override_settings + +from django.core.urlresolvers import reverse + +from django.contrib.auth.models import User, Group +from courseware.access import _course_staff_group_name +from courseware.tests.tests import LoginEnrollmentTestCase, TEST_DATA_XML_MODULESTORE, get_user +from xmodule.modulestore.django import modulestore +import xmodule.modulestore.django +import json + +@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) +class TestStaffMasqueradeAsStudent(LoginEnrollmentTestCase): + ''' + Check for staff being able to masquerade as student + ''' + + def setUp(self): + xmodule.modulestore.django._MODULESTORES = {} + + #self.full = modulestore().get_course("edX/full/6.002_Spring_2012") + #self.toy = modulestore().get_course("edX/toy/2012_Fall") + self.graded_course = modulestore().get_course("edX/graded/2012_Fall") + + # Create staff account + self.instructor = 'view2@test.com' + self.password = 'foo' + self.create_account('u2', self.instructor, self.password) + self.activate_user(self.instructor) + + def make_instructor(course): + group_name = _course_staff_group_name(course.location) + g = Group.objects.create(name=group_name) + g.user_set.add(get_user(self.instructor)) + + make_instructor(self.graded_course) + + self.logout() + self.login(self.instructor, self.password) + self.enroll(self.graded_course) + # self.factory = RequestFactory() + + def get_cw_section(self): + url = reverse('courseware_section', + kwargs={'course_id': self.graded_course.id, + 'chapter': 'GradedChapter', + 'section': 'Homework1'}) + + resp = self.client.get(url) + + print "url ", url + return resp + + def test_staff_debug_for_staff(self): + resp = self.get_cw_section() + sdebug = '' + + self.assertTrue(sdebug in resp.content) + + + def toggle_masquerade(self): + ''' + Toggle masquerade state + ''' + masq_url = reverse('masquerade-switch', kwargs={'marg': 'toggle'}) + print "masq_url ", masq_url + resp = self.client.get(masq_url) + return resp + + def test_no_staff_debug_for_student(self): + togresp = self.toggle_masquerade() + print "masq now ", togresp.content + self.assertEqual(togresp.content, '{"status": "student"}', '') + + resp = self.get_cw_section() + sdebug = '' + + self.assertFalse(sdebug in resp.content) + + def get_problem(self): + pun = 'H1P1' + problem_location = "i4x://edX/graded/problem/%s" % pun + + modx_url = reverse('modx_dispatch', + kwargs={'course_id': self.graded_course.id, + 'location': problem_location, + 'dispatch': 'problem_get', }) + + resp = self.client.get(modx_url) + + print "modx_url ", modx_url + return resp + + def test_showanswer_for_staff(self): + resp = self.get_problem() + html = json.loads(resp.content)['html'] + print html + sabut = '' + self.assertTrue(sabut in html) + + def test_no_showanswer_for_student(self): + togresp = self.toggle_masquerade() + print "masq now ", togresp.content + self.assertEqual(togresp.content, '{"status": "student"}', '') + + resp = self.get_problem() + html = json.loads(resp.content)['html'] + print html + sabut = '' + self.assertFalse(sabut in html) diff --git a/lms/djangoapps/courseware/tests/test_model_data.py b/lms/djangoapps/courseware/tests/test_model_data.py index 65eaa5a4bd..0966fb1aeb 100644 --- a/lms/djangoapps/courseware/tests/test_model_data.py +++ b/lms/djangoapps/courseware/tests/test_model_data.py @@ -1,15 +1,19 @@ -import factory import json from mock import Mock -from django.contrib.auth.models import User - from functools import partial -from courseware.model_data import LmsKeyValueStore, InvalidWriteError, InvalidScopeError, ModelDataCache -from courseware.models import StudentModule, XModuleContentField, XModuleSettingsField, XModuleStudentInfoField, XModuleStudentPrefsField +from courseware.model_data import LmsKeyValueStore, InvalidWriteError +from courseware.model_data import InvalidScopeError, ModelDataCache +from courseware.models import StudentModule, XModuleContentField, XModuleSettingsField +from courseware.models import XModuleStudentInfoField, XModuleStudentPrefsField + +from student.tests.factories import UserFactory +from courseware.tests.factories import StudentModuleFactory as cmfStudentModuleFactory +from courseware.tests.factories import ContentFactory, SettingsFactory +from courseware.tests.factories import StudentPrefsFactory, StudentInfoFactory + from xblock.core import Scope, BlockScope from xmodule.modulestore import Location - from django.test import TestCase @@ -19,6 +23,7 @@ def mock_field(scope, name): field.name = name return field + def mock_descriptor(fields=[], lms_fields=[]): descriptor = Mock() descriptor.stores_state = True @@ -37,53 +42,9 @@ prefs_key = partial(LmsKeyValueStore.Key, Scope.preferences, 'user', 'problem') user_info_key = partial(LmsKeyValueStore.Key, Scope.user_info, 'user', None) -class UserFactory(factory.Factory): - FACTORY_FOR = User - - username = 'user' - - -class StudentModuleFactory(factory.Factory): - FACTORY_FOR = StudentModule - - module_type = 'problem' +class StudentModuleFactory(cmfStudentModuleFactory): module_state_key = location('def_id').url() - student = factory.SubFactory(UserFactory) course_id = course_id - state = None - - -class ContentFactory(factory.Factory): - FACTORY_FOR = XModuleContentField - - field_name = 'existing_field' - value = json.dumps('old_value') - definition_id = location('def_id').url() - - -class SettingsFactory(factory.Factory): - FACTORY_FOR = XModuleSettingsField - - field_name = 'existing_field' - value = json.dumps('old_value') - usage_id = '%s-%s' % (course_id, location('def_id').url()) - - -class StudentPrefsFactory(factory.Factory): - FACTORY_FOR = XModuleStudentPrefsField - - field_name = 'existing_field' - value = json.dumps('old_value') - student = factory.SubFactory(UserFactory) - module_type = 'problem' - - -class StudentInfoFactory(factory.Factory): - FACTORY_FOR = XModuleStudentInfoField - - field_name = 'existing_field' - value = json.dumps('old_value') - student = factory.SubFactory(UserFactory) class TestDescriptorFallback(TestCase): @@ -114,7 +75,7 @@ class TestDescriptorFallback(TestCase): class TestInvalidScopes(TestCase): def setUp(self): self.desc_md = {} - self.user = UserFactory.create() + self.user = UserFactory.create(username='user') self.mdc = ModelDataCache([mock_descriptor([mock_field(Scope.user_state, 'a_field')])], course_id, self.user) self.kvs = LmsKeyValueStore(self.desc_md, self.mdc) @@ -180,7 +141,7 @@ class TestStudentModuleStorage(TestCase): class TestMissingStudentModule(TestCase): def setUp(self): - self.user = UserFactory.create() + self.user = UserFactory.create(username='user') self.desc_md = {} self.mdc = ModelDataCache([mock_descriptor()], course_id, self.user) self.kvs = LmsKeyValueStore(self.desc_md, self.mdc) diff --git a/lms/djangoapps/courseware/tests/test_module_render.py b/lms/djangoapps/courseware/tests/test_module_render.py index 0e4ba8ba5e..94ab4b7e94 100644 --- a/lms/djangoapps/courseware/tests/test_module_render.py +++ b/lms/djangoapps/courseware/tests/test_module_render.py @@ -69,19 +69,38 @@ class ModuleRenderTestCase(LoginEnrollmentTestCase): json.dumps({'success': 'Submission aborted! Your file "%s" is too large (max size: %d MB)' % (inputfile.name, settings.STUDENT_FILEUPLOAD_MAX_SIZE / (1000 ** 2))})) mock_request_3 = MagicMock() - mock_request_3.POST.copy.return_value = {} + mock_request_3.POST.copy.return_value = {'position': 1} mock_request_3.FILES = False mock_request_3.user = UserFactory() inputfile_2 = Stub() inputfile_2.size = 1 inputfile_2.name = 'name' - self.assertRaises(ItemNotFoundError, render.modx_dispatch, - mock_request_3, 'dummy', self.location, 'toy') - self.assertRaises(Http404, render.modx_dispatch, mock_request_3, 'dummy', - self.location, self.course_id) - mock_request_3.POST.copy.return_value = {'position': 1} self.assertIsInstance(render.modx_dispatch(mock_request_3, 'goto_position', self.location, self.course_id), HttpResponse) + self.assertRaises( + Http404, + render.modx_dispatch, + mock_request_3, + 'goto_position', + self.location, + 'bad_course_id' + ) + self.assertRaises( + Http404, + render.modx_dispatch, + mock_request_3, + 'goto_position', + ['i4x', 'edX', 'toy', 'chapter', 'bad_location'], + self.course_id + ) + self.assertRaises( + Http404, + render.modx_dispatch, + mock_request_3, + 'bad_dispatch', + self.location, + self.course_id + ) def test_get_score_bucket(self): self.assertEquals(render.get_score_bucket(0, 10), 'incorrect') diff --git a/lms/djangoapps/courseware/tests/test_tabs.py b/lms/djangoapps/courseware/tests/test_tabs.py index 928b9ae0df..04c46a7820 100644 --- a/lms/djangoapps/courseware/tests/test_tabs.py +++ b/lms/djangoapps/courseware/tests/test_tabs.py @@ -1,11 +1,15 @@ from django.test import TestCase from mock import MagicMock +from mock import patch import courseware.tabs as tabs from django.test.utils import override_settings from django.core.urlresolvers import reverse +from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory class ProgressTestCase(TestCase): @@ -257,3 +261,62 @@ class ValidateTabsTestCase(TestCase): self.assertRaises(tabs.InvalidTabsException, tabs.validate_tabs, self.courses[2]) self.assertIsNone(tabs.validate_tabs(self.courses[3])) self.assertRaises(tabs.InvalidTabsException, tabs.validate_tabs, self.courses[4]) + + +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +class DiscussionLinkTestCase(ModuleStoreTestCase): + + def setUp(self): + self.tabs_with_discussion = [ + {'type':'courseware'}, + {'type':'course_info'}, + {'type':'discussion'}, + {'type':'textbooks'}, + ] + self.tabs_without_discussion = [ + {'type':'courseware'}, + {'type':'course_info'}, + {'type':'textbooks'}, + ] + + @staticmethod + def _patch_reverse(course): + def patched_reverse(viewname, args): + if viewname == "django_comment_client.forum.views.forum_form_discussion" and args == [course.id]: + return "default_discussion_link" + else: + return None + return patch("courseware.tabs.reverse", patched_reverse) + + @patch.dict("django.conf.settings.MITX_FEATURES", {"ENABLE_DISCUSSION_SERVICE": False}) + def test_explicit_discussion_link(self): + """Test that setting discussion_link overrides everything else""" + course = CourseFactory.create(discussion_link="other_discussion_link", tabs=self.tabs_with_discussion) + self.assertEqual(tabs.get_discussion_link(course), "other_discussion_link") + + @patch.dict("django.conf.settings.MITX_FEATURES", {"ENABLE_DISCUSSION_SERVICE": False}) + def test_discussions_disabled(self): + """Test that other cases return None with discussions disabled""" + for i, t in enumerate([None, self.tabs_with_discussion, self.tabs_without_discussion]): + course = CourseFactory.create(tabs=t, number=str(i)) + self.assertEqual(tabs.get_discussion_link(course), None) + + @patch.dict("django.conf.settings.MITX_FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + def test_no_tabs(self): + """Test a course without tabs configured""" + course = CourseFactory.create(tabs=None) + with self._patch_reverse(course): + self.assertEqual(tabs.get_discussion_link(course), "default_discussion_link") + + @patch.dict("django.conf.settings.MITX_FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + def test_tabs_with_discussion(self): + """Test a course with a discussion tab configured""" + course = CourseFactory.create(tabs=self.tabs_with_discussion) + with self._patch_reverse(course): + self.assertEqual(tabs.get_discussion_link(course), "default_discussion_link") + + @patch.dict("django.conf.settings.MITX_FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + def test_tabs_without_discussion(self): + """Test a course with tabs configured but without a discussion tab""" + course = CourseFactory.create(tabs=self.tabs_without_discussion) + self.assertEqual(tabs.get_discussion_link(course), None) diff --git a/lms/djangoapps/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py index 5613f8831f..ec3e55b1b8 100644 --- a/lms/djangoapps/courseware/tests/tests.py +++ b/lms/djangoapps/courseware/tests/tests.py @@ -1,13 +1,13 @@ ''' Test for lms courseware app ''' - import logging import json import time import random from urlparse import urlsplit, urlunsplit +from uuid import uuid4 from django.contrib.auth.models import User, Group from django.test import TestCase @@ -55,19 +55,21 @@ def mongo_store_config(data_dir): Use of this config requires mongo to be running ''' - return { + store = { 'default': { 'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore', 'OPTIONS': { 'default_class': 'xmodule.raw_module.RawDescriptor', 'host': 'localhost', 'db': 'test_xmodule', - 'collection': 'modulestore', + 'collection': 'modulestore_%s' % uuid4().hex, 'fs_root': data_dir, 'render_template': 'mitxmako.shortcuts.render_to_string', } } } + store['direct'] = store['default'] + return store def draft_mongo_store_config(data_dir): @@ -79,7 +81,18 @@ def draft_mongo_store_config(data_dir): 'default_class': 'xmodule.raw_module.RawDescriptor', 'host': 'localhost', 'db': 'test_xmodule', - 'collection': 'modulestore', + 'collection': 'modulestore_%s' % uuid4().hex, + 'fs_root': data_dir, + 'render_template': 'mitxmako.shortcuts.render_to_string', + } + }, + 'direct': { + 'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore', + 'OPTIONS': { + 'default_class': 'xmodule.raw_module.RawDescriptor', + 'host': 'localhost', + 'db': 'test_xmodule', + 'collection': 'modulestore_%s' % uuid4().hex, 'fs_root': data_dir, 'render_template': 'mitxmako.shortcuts.render_to_string', } @@ -207,25 +220,20 @@ class LoginEnrollmentTestCase(TestCase): # Now make sure that the user is now actually activated self.assertTrue(get_user(email).is_active) - def _enroll(self, course): - """Post to the enrollment view, and return the parsed json response""" + def try_enroll(self, course): + """Try to enroll. Return bool success instead of asserting it.""" resp = self.client.post('/change_enrollment', { 'enrollment_action': 'enroll', 'course_id': course.id, }) - return parse_json(resp) - - def try_enroll(self, course): - """Try to enroll. Return bool success instead of asserting it.""" - data = self._enroll(course) - print ('Enrollment in %s result: %s' - % (course.location.url(), str(data))) - return data['success'] + print ('Enrollment in %s result status code: %s' + % (course.location.url(), str(resp.status_code))) + return resp.status_code == 200 def enroll(self, course): """Enroll the currently logged-in user, and check that it worked.""" - data = self._enroll(course) - self.assertTrue(data['success']) + result = self.try_enroll(course) + self.assertTrue(result) def unenroll(self, course): """Unenroll the currently logged-in user, and check that it worked.""" @@ -233,8 +241,7 @@ class LoginEnrollmentTestCase(TestCase): 'enrollment_action': 'unenroll', 'course_id': course.id, }) - data = parse_json(resp) - self.assertTrue(data['success']) + self.assertTrue(resp.status_code == 200) def check_for_get_code(self, code, url): """ @@ -359,6 +366,7 @@ class TestCoursesLoadTestCase_XmlModulestore(PageLoaderTestCase): '''Check that all pages in test courses load properly from XML''' def setUp(self): + super(TestCoursesLoadTestCase_XmlModulestore, self).setUp() self.setup_viewtest_user() xmodule.modulestore.django._MODULESTORES = {} @@ -377,6 +385,7 @@ class TestCoursesLoadTestCase_MongoModulestore(PageLoaderTestCase): '''Check that all pages in test courses load properly from Mongo''' def setUp(self): + super(TestCoursesLoadTestCase_MongoModulestore, self).setUp() self.setup_viewtest_user() xmodule.modulestore.django._MODULESTORES = {} modulestore().collection.drop() @@ -386,6 +395,14 @@ class TestCoursesLoadTestCase_MongoModulestore(PageLoaderTestCase): import_from_xml(module_store, TEST_DATA_DIR, ['toy']) self.check_random_page_loads(module_store) + def test_full_textbooks_loads(self): + module_store = modulestore() + import_from_xml(module_store, TEST_DATA_DIR, ['full']) + + course = module_store.get_item(Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None])) + + self.assertGreater(len(course.textbooks), 0) + @override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) class TestNavigation(LoginEnrollmentTestCase): @@ -466,9 +483,6 @@ class TestDraftModuleStore(TestCase): class TestViewAuth(LoginEnrollmentTestCase): """Check that view authentication works properly""" - # NOTE: setUpClass() runs before override_settings takes effect, so - # can't do imports there without manually hacking settings. - def setUp(self): xmodule.modulestore.django._MODULESTORES = {} @@ -610,8 +624,8 @@ class TestViewAuth(LoginEnrollmentTestCase): urls = reverse_urls(['info', 'progress'], course) urls.extend([ reverse('book', kwargs={'course_id': course.id, - 'book_index': book.title}) - for book in course.textbooks + 'book_index': index}) + for index, book in enumerate(course.textbooks) ]) return urls @@ -622,8 +636,6 @@ class TestViewAuth(LoginEnrollmentTestCase): """ urls = reverse_urls(['about_course'], course) urls.append(reverse('courses')) - # Need separate test for change_enrollment, since it's a POST view - #urls.append(reverse('change_enrollment')) return urls @@ -791,43 +803,85 @@ class TestViewAuth(LoginEnrollmentTestCase): @override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) -class TestCourseGrader(LoginEnrollmentTestCase): +class TestSubmittingProblems(LoginEnrollmentTestCase): """Check that a course gets graded properly""" - # NOTE: setUpClass() runs before override_settings takes effect, so - # can't do imports there without manually hacking settings. + # Subclasses should specify the course slug + course_slug = "UNKNOWN" + course_when = "UNKNOWN" def setUp(self): xmodule.modulestore.django._MODULESTORES = {} - courses = modulestore().get_courses() - def find_course(course_id): - """Assumes the course is present""" - return [c for c in courses if c.id == course_id][0] - - self.graded_course = find_course("edX/graded/2012_Fall") + course_name = "edX/%s/%s" % (self.course_slug, self.course_when) + self.course = modulestore().get_course(course_name) + assert self.course, "Couldn't load course %r" % course_name # create a test student self.student = 'view@test.com' self.password = 'foo' self.create_account('u1', self.student, self.password) self.activate_user(self.student) - self.enroll(self.graded_course) + self.enroll(self.course) self.student_user = get_user(self.student) self.factory = RequestFactory() + def problem_location(self, problem_url_name): + return "i4x://edX/{}/problem/{}".format(self.course_slug, problem_url_name) + + def modx_url(self, problem_location, dispatch): + return reverse( + 'modx_dispatch', + kwargs={ + 'course_id': self.course.id, + 'location': problem_location, + 'dispatch': dispatch, + } + ) + + def submit_question_answer(self, problem_url_name, responses): + """ + Submit answers to a question. + + Responses is a dict mapping problem ids (not sure of the right term) + to answers: + {'2_1': 'Correct', '2_2': 'Incorrect'} + + """ + problem_location = self.problem_location(problem_url_name) + modx_url = self.modx_url(problem_location, 'problem_check') + answer_key_prefix = 'input_i4x-edX-{}-problem-{}_'.format(self.course_slug, problem_url_name) + resp = self.client.post(modx_url, + { (answer_key_prefix + k): v for k,v in responses.items() } + ) + return resp + + def reset_question_answer(self, problem_url_name): + '''resets specified problem for current user''' + problem_location = self.problem_location(problem_url_name) + modx_url = self.modx_url(problem_location, 'problem_reset') + resp = self.client.post(modx_url) + return resp + + +class TestCourseGrader(TestSubmittingProblems): + """Check that a course gets graded properly""" + + course_slug = "graded" + course_when = "2012_Fall" + def get_grade_summary(self): '''calls grades.grade for current user and course''' model_data_cache = ModelDataCache.cache_for_descriptor_descendents( - self.graded_course.id, self.student_user, self.graded_course) + self.course.id, self.student_user, self.course) fake_request = self.factory.get(reverse('progress', - kwargs={'course_id': self.graded_course.id})) + kwargs={'course_id': self.course.id})) return grades.grade(self.student_user, fake_request, - self.graded_course, model_data_cache) + self.course, model_data_cache) def get_homework_scores(self): '''get scores for homeworks''' @@ -836,14 +890,14 @@ class TestCourseGrader(LoginEnrollmentTestCase): def get_progress_summary(self): '''return progress summary structure for current user and course''' model_data_cache = ModelDataCache.cache_for_descriptor_descendents( - self.graded_course.id, self.student_user, self.graded_course) + self.course.id, self.student_user, self.course) fake_request = self.factory.get(reverse('progress', - kwargs={'course_id': self.graded_course.id})) + kwargs={'course_id': self.course.id})) progress_summary = grades.progress_summary(self.student_user, fake_request, - self.graded_course, + self.course, model_data_cache) return progress_summary @@ -852,46 +906,6 @@ class TestCourseGrader(LoginEnrollmentTestCase): grade_summary = self.get_grade_summary() self.assertEqual(grade_summary['percent'], percent) - def submit_question_answer(self, problem_url_name, responses): - """ - The field names of a problem are hard to determine. This method only works - for the problems used in the edX/graded course, which has fields named in the - following form: - input_i4x-edX-graded-problem-H1P3_2_1 - input_i4x-edX-graded-problem-H1P3_2_2 - """ - problem_location = "i4x://edX/graded/problem/%s" % problem_url_name - - modx_url = reverse('modx_dispatch', - kwargs={'course_id': self.graded_course.id, - 'location': problem_location, - 'dispatch': 'problem_check', }) - - resp = self.client.post(modx_url, { - 'input_i4x-edX-graded-problem-%s_2_1' % problem_url_name: responses[0], - 'input_i4x-edX-graded-problem-%s_2_2' % problem_url_name: responses[1], - }) - print "modx_url", modx_url, "responses", responses - print "resp", resp - - return resp - - def problem_location(self, problem_url_name): - '''Get location string for problem, assuming hardcoded course_id''' - return "i4x://edX/graded/problem/{0}".format(problem_url_name) - - def reset_question_answer(self, problem_url_name): - '''resets specified problem for current user''' - problem_location = self.problem_location(problem_url_name) - - modx_url = reverse('modx_dispatch', - kwargs={'course_id': self.graded_course.id, - 'location': problem_location, - 'dispatch': 'problem_reset', }) - - resp = self.client.post(modx_url) - return resp - def test_get_graded(self): #### Check that the grader shows we have 0% in the course self.check_grade_percent(0) @@ -909,27 +923,27 @@ class TestCourseGrader(LoginEnrollmentTestCase): return [s.earned for s in hw_section['scores']] # Only get half of the first problem correct - self.submit_question_answer('H1P1', ['Correct', 'Incorrect']) + self.submit_question_answer('H1P1', {'2_1': 'Correct', '2_2': 'Incorrect'}) self.check_grade_percent(0.06) self.assertEqual(earned_hw_scores(), [1.0, 0, 0]) # Order matters self.assertEqual(score_for_hw('Homework1'), [1.0, 0.0]) # Get both parts of the first problem correct self.reset_question_answer('H1P1') - self.submit_question_answer('H1P1', ['Correct', 'Correct']) + self.submit_question_answer('H1P1', {'2_1': 'Correct', '2_2': 'Correct'}) self.check_grade_percent(0.13) self.assertEqual(earned_hw_scores(), [2.0, 0, 0]) self.assertEqual(score_for_hw('Homework1'), [2.0, 0.0]) # This problem is shown in an ABTest - self.submit_question_answer('H1P2', ['Correct', 'Correct']) + self.submit_question_answer('H1P2', {'2_1': 'Correct', '2_2': 'Correct'}) self.check_grade_percent(0.25) self.assertEqual(earned_hw_scores(), [4.0, 0.0, 0]) self.assertEqual(score_for_hw('Homework1'), [2.0, 2.0]) # This problem is hidden in an ABTest. # Getting it correct doesn't change total grade - self.submit_question_answer('H1P3', ['Correct', 'Correct']) + self.submit_question_answer('H1P3', {'2_1': 'Correct', '2_2': 'Correct'}) self.check_grade_percent(0.25) self.assertEqual(score_for_hw('Homework1'), [2.0, 2.0]) @@ -938,19 +952,85 @@ class TestCourseGrader(LoginEnrollmentTestCase): # This problem is also weighted to be 4 points (instead of default of 2) # If the problem was unweighted the percent would have been 0.38 so we # know it works. - self.submit_question_answer('H2P1', ['Correct', 'Correct']) + self.submit_question_answer('H2P1', {'2_1': 'Correct', '2_2': 'Correct'}) self.check_grade_percent(0.42) self.assertEqual(earned_hw_scores(), [4.0, 4.0, 0]) # Third homework - self.submit_question_answer('H3P1', ['Correct', 'Correct']) + self.submit_question_answer('H3P1', {'2_1': 'Correct', '2_2': 'Correct'}) self.check_grade_percent(0.42) # Score didn't change self.assertEqual(earned_hw_scores(), [4.0, 4.0, 2.0]) - self.submit_question_answer('H3P2', ['Correct', 'Correct']) + self.submit_question_answer('H3P2', {'2_1': 'Correct', '2_2': 'Correct'}) self.check_grade_percent(0.5) # Now homework2 dropped. Score changes self.assertEqual(earned_hw_scores(), [4.0, 4.0, 4.0]) # Now we answer the final question (worth half of the grade) - self.submit_question_answer('FinalQuestion', ['Correct', 'Correct']) + self.submit_question_answer('FinalQuestion', {'2_1': 'Correct', '2_2': 'Correct'}) self.check_grade_percent(1.0) # Hooray! We got 100% + + +@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) +class TestSchematicResponse(TestSubmittingProblems): + """Check that we can submit a schematic response, and it answers properly.""" + + course_slug = "embedded_python" + course_when = "2013_Spring" + + def test_schematic(self): + resp = self.submit_question_answer('schematic_problem', + { '2_1': json.dumps( + [['transient', {'Z': [ + [0.0000004, 2.8], + [0.0000009, 2.8], + [0.0000014, 2.8], + [0.0000019, 2.8], + [0.0000024, 2.8], + [0.0000029, 0.2], + [0.0000034, 0.2], + [0.0000039, 0.2] + ]}]] + ) + }) + respdata = json.loads(resp.content) + self.assertEqual(respdata['success'], 'correct') + + self.reset_question_answer('schematic_problem') + resp = self.submit_question_answer('schematic_problem', + { '2_1': json.dumps( + [['transient', {'Z': [ + [0.0000004, 2.8], + [0.0000009, 0.0], # wrong. + [0.0000014, 2.8], + [0.0000019, 2.8], + [0.0000024, 2.8], + [0.0000029, 0.2], + [0.0000034, 0.2], + [0.0000039, 0.2] + ]}]] + ) + }) + respdata = json.loads(resp.content) + self.assertEqual(respdata['success'], 'incorrect') + + def test_check_function(self): + resp = self.submit_question_answer('cfn_problem', {'2_1': "0, 1, 2, 3, 4, 5, 'Outside of loop', 6"}) + respdata = json.loads(resp.content) + self.assertEqual(respdata['success'], 'correct') + + self.reset_question_answer('cfn_problem') + + resp = self.submit_question_answer('cfn_problem', {'2_1': "xyzzy!"}) + respdata = json.loads(resp.content) + self.assertEqual(respdata['success'], 'incorrect') + + def test_computed_answer(self): + resp = self.submit_question_answer('computed_answer', {'2_1': "Xyzzy"}) + respdata = json.loads(resp.content) + self.assertEqual(respdata['success'], 'correct') + + self.reset_question_answer('computed_answer') + + resp = self.submit_question_answer('computed_answer', {'2_1': "NO!"}) + respdata = json.loads(resp.content) + self.assertEqual(respdata['success'], 'incorrect') diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index 714e45842f..9c5a665754 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -20,6 +20,7 @@ from courseware.access import has_access from courseware.courses import (get_courses, get_course_with_access, get_courses_by_university, sort_by_announcement) import courseware.tabs as tabs +from courseware.masquerade import setup_masquerade from courseware.model_data import ModelDataCache from .module_render import toc_for_course, get_module_for_descriptor, get_module from courseware.models import StudentModule, StudentModuleHistory @@ -89,6 +90,7 @@ def render_accordion(request, course, chapter, section, model_data_cache): # grab the table of contents user = User.objects.prefetch_related("groups").get(id=request.user.id) + request.user = user # keep just one instance of User toc = toc_for_course(user, request, course, chapter, section, model_data_cache) context = dict([('toc', toc), @@ -260,6 +262,7 @@ def index(request, course_id, chapter=None, section=None, - HTTPresponse """ user = User.objects.prefetch_related("groups").get(id=request.user.id) + request.user = user # keep just one instance of User course = get_course_with_access(user, course_id, 'load', depth=2) staff_access = has_access(user, course, 'staff') registered = registered_for_course(course, user) @@ -268,6 +271,8 @@ def index(request, course_id, chapter=None, section=None, log.debug('User %s tried to view course %s but is not enrolled' % (user, course.location.url())) return redirect(reverse('about_course', args=[course.id])) + masq = setup_masquerade(request, staff_access) + try: model_data_cache = ModelDataCache.cache_for_descriptor_descendents( course.id, user, course, depth=2) @@ -289,6 +294,7 @@ def index(request, course_id, chapter=None, section=None, 'init': '', 'content': '', 'staff_access': staff_access, + 'masquerade': masq, 'xqa_server': settings.MITX_FEATURES.get('USE_XQA_SERVER', 'http://xqa:server@content-qa.mitx.mit.edu/xqa') } @@ -301,12 +307,18 @@ def index(request, course_id, chapter=None, section=None, chapter_module = course_module.get_child_by(lambda m: m.url_name == chapter) if chapter_module is None: # User may be trying to access a chapter that isn't live yet + if masq=='student': # if staff is masquerading as student be kinder, don't 404 + log.debug('staff masq as student: no chapter %s' % chapter) + return redirect(reverse('courseware', args=[course.id])) raise Http404 if section is not None: section_descriptor = chapter_descriptor.get_child_by(lambda m: m.url_name == section) if section_descriptor is None: # Specifically asked-for section doesn't exist + if masq=='student': # if staff is masquerading as student be kinder, don't 404 + log.debug('staff masq as student: no section %s' % section) + return redirect(reverse('courseware', args=[course.id])) raise Http404 # cdodge: this looks silly, but let's refetch the section_descriptor with depth=None @@ -437,9 +449,10 @@ def course_info(request, course_id): """ course = get_course_with_access(request.user, course_id, 'load') staff_access = has_access(request.user, course, 'staff') + masq = setup_masquerade(request, staff_access) # allow staff to toggle masquerade on info page return render_to_response('courseware/info.html', {'request': request, 'course_id': course_id, 'cache': None, - 'course': course, 'staff_access': staff_access}) + 'course': course, 'staff_access': staff_access, 'masquerade': masq}) @ensure_csrf_cookie @@ -502,6 +515,9 @@ def registered_for_course(course, user): @ensure_csrf_cookie @cache_if_anonymous def course_about(request, course_id): + if settings.MITX_FEATURES.get('ENABLE_MKTG_SITE', False): + raise Http404 + course = get_course_with_access(request.user, course_id, 'see_exists') registered = registered_for_course(course, request.user) @@ -518,6 +534,37 @@ def course_about(request, course_id): 'registered': registered, 'course_target': course_target, 'show_courseware_link': show_courseware_link}) +@ensure_csrf_cookie +@cache_if_anonymous +def mktg_course_about(request, course_id): + + try: + course = get_course_with_access(request.user, course_id, 'see_exists') + except (ValueError, Http404) as e: + # if a course does not exist yet, display a coming + # soon button + return render_to_response('courseware/mktg_coming_soon.html', + {'course_id': course_id}) + + registered = registered_for_course(course, request.user) + + if has_access(request.user, course, 'load'): + course_target = reverse('info', args=[course.id]) + else: + course_target = reverse('about_course', args=[course.id]) + + allow_registration = has_access(request.user, course, 'enroll') + + show_courseware_link = (has_access(request.user, course, 'load') or + settings.MITX_FEATURES.get('ENABLE_LMS_MIGRATION')) + + return render_to_response('courseware/mktg_course_about.html', + {'course': course, + 'registered': registered, + 'allow_registration': allow_registration, + 'course_target': course_target, + 'show_courseware_link': show_courseware_link}) + @ensure_csrf_cookie diff --git a/lms/djangoapps/debug/__init__.py b/lms/djangoapps/debug/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/debug/models.py b/lms/djangoapps/debug/models.py new file mode 100644 index 0000000000..71a8362390 --- /dev/null +++ b/lms/djangoapps/debug/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/lms/djangoapps/debug/views.py b/lms/djangoapps/debug/views.py new file mode 100644 index 0000000000..c1d4155fdd --- /dev/null +++ b/lms/djangoapps/debug/views.py @@ -0,0 +1,31 @@ +"""Views for debugging and diagnostics""" + +import pprint +import traceback + +from django.http import Http404 +from django.contrib.auth.decorators import login_required +from django_future.csrf import ensure_csrf_cookie, csrf_exempt +from mitxmako.shortcuts import render_to_response + +from codejail.safe_exec import safe_exec + +@login_required +@ensure_csrf_cookie +def run_python(request): + """A page to allow testing the Python sandbox on a production server.""" + if not request.user.is_staff: + raise Http404 + c = {} + c['code'] = '' + c['results'] = None + if request.method == 'POST': + py_code = c['code'] = request.POST.get('code') + g = {} + try: + safe_exec(py_code, g) + except Exception as e: + c['results'] = traceback.format_exc() + else: + c['results'] = pprint.pformat(g) + return render_to_response("debug/run_python_form.html", c) diff --git a/lms/djangoapps/django_comment_client/base/tests.py b/lms/djangoapps/django_comment_client/base/tests.py new file mode 100644 index 0000000000..3e06402ddd --- /dev/null +++ b/lms/djangoapps/django_comment_client/base/tests.py @@ -0,0 +1,217 @@ +import logging + +from django.test.utils import override_settings +from django.test.client import Client +from django.contrib.auth.models import User +from student.tests.factories import CourseEnrollmentFactory +from xmodule.modulestore.tests.factories import CourseFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from django.core.urlresolvers import reverse +from django.core.management import call_command + +from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE +from nose.tools import assert_true, assert_equal +from mock import patch + +log = logging.getLogger(__name__) + + +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +@patch('comment_client.utils.requests.request') +class ViewsTestCase(ModuleStoreTestCase): + def setUp(self): + # create a course + self.course = CourseFactory.create(org='MITx', course='999', + display_name='Robot Super Course') + self.course_id = self.course.id + # seed the forums permissions and roles + call_command('seed_permissions_roles', self.course_id) + + # Patch the comment client user save method so it does not try + # to create a new cc user when creating a django user + with patch('student.models.cc.User.save'): + uname = 'student' + email = 'student@edx.org' + password = 'test' + + # Create the user and make them active so we can log them in. + self.student = User.objects.create_user(uname, email, password) + self.student.is_active = True + self.student.save() + + # Enroll the student in the course + CourseEnrollmentFactory(user=self.student, + course_id=self.course_id) + + self.client = Client() + assert_true(self.client.login(username='student', password='test')) + + def test_create_thread(self, mock_request): + mock_request.return_value.status_code = 200 + mock_request.return_value.text = u'{"title":"Hello",\ + "body":"this is a post",\ + "course_id":"MITx/999/Robot_Super_Course",\ + "anonymous":false,\ + "anonymous_to_peers":false,\ + "commentable_id":"i4x-MITx-999-course-Robot_Super_Course",\ + "created_at":"2013-05-10T18:53:43Z",\ + "updated_at":"2013-05-10T18:53:43Z",\ + "at_position_list":[],\ + "closed":false,\ + "id":"518d4237b023791dca00000d",\ + "user_id":"1","username":"robot",\ + "votes":{"count":0,"up_count":0,\ + "down_count":0,"point":0},\ + "abuse_flaggers":[],"tags":[],\ + "type":"thread","group_id":null,\ + "pinned":false,\ + "endorsed":false,\ + "unread_comments_count":0,\ + "read":false,"comments_count":0}' + thread = {"body": ["this is a post"], + "anonymous_to_peers": ["false"], + "auto_subscribe": ["false"], + "anonymous": ["false"], + "title": ["Hello"] + } + url = reverse('create_thread', kwargs={'commentable_id': 'i4x-MITx-999-course-Robot_Super_Course', + 'course_id': self.course_id}) + response = self.client.post(url, data=thread) + assert_true(mock_request.called) + mock_request.assert_called_with('post', + 'http://localhost:4567/api/v1/i4x-MITx-999-course-Robot_Super_Course/threads', + data={'body': u'this is a post', + 'anonymous_to_peers': False, 'user_id': 1, + 'title': u'Hello', + 'commentable_id': u'i4x-MITx-999-course-Robot_Super_Course', + 'anonymous': False, 'course_id': u'MITx/999/Robot_Super_Course', + 'api_key': 'PUT_YOUR_API_KEY_HERE'}, timeout=5) + assert_equal(response.status_code, 200) + + def test_flag_thread(self, mock_request): + mock_request.return_value.status_code = 200 + mock_request.return_value.text = u'{"title":"Hello",\ + "body":"this is a post",\ + "course_id":"MITx/999/Robot_Super_Course",\ + "anonymous":false,\ + "anonymous_to_peers":false,\ + "commentable_id":"i4x-MITx-999-course-Robot_Super_Course",\ + "created_at":"2013-05-10T18:53:43Z",\ + "updated_at":"2013-05-10T18:53:43Z",\ + "at_position_list":[],\ + "closed":false,\ + "id":"518d4237b023791dca00000d",\ + "user_id":"1","username":"robot",\ + "votes":{"count":0,"up_count":0,\ + "down_count":0,"point":0},\ + "abuse_flaggers":[1],"tags":[],\ + "type":"thread","group_id":null,\ + "pinned":false,\ + "endorsed":false,\ + "unread_comments_count":0,\ + "read":false,"comments_count":0}' + url = reverse('flag_abuse_for_thread', kwargs={'thread_id': '518d4237b023791dca00000d', 'course_id': self.course_id}) + response = self.client.post(url) + assert_true(mock_request.called) + + call_list = [(('get', 'http://localhost:4567/api/v1/threads/518d4237b023791dca00000d'), {'params': {'mark_as_read': True, 'api_key': 'PUT_YOUR_API_KEY_HERE'}, 'timeout': 5}), + (('put', 'http://localhost:4567/api/v1/threads/518d4237b023791dca00000d/abuse_flag'), {'data': {'api_key': 'PUT_YOUR_API_KEY_HERE', 'user_id': '1'}, 'timeout': 5}), + (('get', 'http://localhost:4567/api/v1/threads/518d4237b023791dca00000d'), {'params': {'mark_as_read': True, 'api_key': 'PUT_YOUR_API_KEY_HERE'}, 'timeout': 5})] + + assert_equal(call_list, mock_request.call_args_list) + + assert_equal(response.status_code, 200) + + def test_un_flag_thread(self, mock_request): + mock_request.return_value.status_code = 200 + mock_request.return_value.text = u'{"title":"Hello",\ + "body":"this is a post",\ + "course_id":"MITx/999/Robot_Super_Course",\ + "anonymous":false,\ + "anonymous_to_peers":false,\ + "commentable_id":"i4x-MITx-999-course-Robot_Super_Course",\ + "created_at":"2013-05-10T18:53:43Z",\ + "updated_at":"2013-05-10T18:53:43Z",\ + "at_position_list":[],\ + "closed":false,\ + "id":"518d4237b023791dca00000d",\ + "user_id":"1","username":"robot",\ + "votes":{"count":0,"up_count":0,\ + "down_count":0,"point":0},\ + "abuse_flaggers":[],"tags":[],\ + "type":"thread","group_id":null,\ + "pinned":false,\ + "endorsed":false,\ + "unread_comments_count":0,\ + "read":false,"comments_count":0}' + url = reverse('un_flag_abuse_for_thread', kwargs={'thread_id': '518d4237b023791dca00000d', 'course_id': self.course_id}) + response = self.client.post(url) + assert_true(mock_request.called) + + call_list = [(('get', 'http://localhost:4567/api/v1/threads/518d4237b023791dca00000d'), {'params': {'mark_as_read': True, 'api_key': 'PUT_YOUR_API_KEY_HERE'}, 'timeout': 5}), + (('put', 'http://localhost:4567/api/v1/threads/518d4237b023791dca00000d/abuse_unflag'), {'data': {'api_key': 'PUT_YOUR_API_KEY_HERE', 'user_id': '1'}, 'timeout': 5}), + (('get', 'http://localhost:4567/api/v1/threads/518d4237b023791dca00000d'), {'params': {'mark_as_read': True, 'api_key': 'PUT_YOUR_API_KEY_HERE'}, 'timeout': 5})] + + assert_equal(call_list, mock_request.call_args_list) + + assert_equal(response.status_code, 200) + + def test_flag_comment(self, mock_request): + mock_request.return_value.status_code = 200 + mock_request.return_value.text = u'{"body":"this is a comment",\ + "course_id":"MITx/999/Robot_Super_Course",\ + "anonymous":false,\ + "anonymous_to_peers":false,\ + "commentable_id":"i4x-MITx-999-course-Robot_Super_Course",\ + "created_at":"2013-05-10T18:53:43Z",\ + "updated_at":"2013-05-10T18:53:43Z",\ + "at_position_list":[],\ + "closed":false,\ + "id":"518d4237b023791dca00000d",\ + "user_id":"1","username":"robot",\ + "votes":{"count":0,"up_count":0,\ + "down_count":0,"point":0},\ + "abuse_flaggers":[1],\ + "type":"comment",\ + "endorsed":false}' + url = reverse('flag_abuse_for_comment', kwargs={'comment_id': '518d4237b023791dca00000d', 'course_id': self.course_id}) + response = self.client.post(url) + assert_true(mock_request.called) + + call_list = [(('get', 'http://localhost:4567/api/v1/comments/518d4237b023791dca00000d'), {'params': {'api_key': 'PUT_YOUR_API_KEY_HERE'}, 'timeout': 5}), + (('put', 'http://localhost:4567/api/v1/comments/518d4237b023791dca00000d/abuse_flag'), {'data': {'api_key': 'PUT_YOUR_API_KEY_HERE', 'user_id': '1'}, 'timeout': 5}), + (('get', 'http://localhost:4567/api/v1/comments/518d4237b023791dca00000d'), {'params': {'api_key': 'PUT_YOUR_API_KEY_HERE'}, 'timeout': 5})] + + assert_equal(call_list, mock_request.call_args_list) + + assert_equal(response.status_code, 200) + + def test_un_flag_comment(self, mock_request): + mock_request.return_value.status_code = 200 + mock_request.return_value.text = u'{"body":"this is a comment",\ + "course_id":"MITx/999/Robot_Super_Course",\ + "anonymous":false,\ + "anonymous_to_peers":false,\ + "commentable_id":"i4x-MITx-999-course-Robot_Super_Course",\ + "created_at":"2013-05-10T18:53:43Z",\ + "updated_at":"2013-05-10T18:53:43Z",\ + "at_position_list":[],\ + "closed":false,\ + "id":"518d4237b023791dca00000d",\ + "user_id":"1","username":"robot",\ + "votes":{"count":0,"up_count":0,\ + "down_count":0,"point":0},\ + "abuse_flaggers":[],\ + "type":"comment",\ + "endorsed":false}' + url = reverse('un_flag_abuse_for_comment', kwargs={'comment_id': '518d4237b023791dca00000d', 'course_id': self.course_id}) + response = self.client.post(url) + assert_true(mock_request.called) + + call_list = [(('get', 'http://localhost:4567/api/v1/comments/518d4237b023791dca00000d'), {'params': {'api_key': 'PUT_YOUR_API_KEY_HERE'}, 'timeout': 5}), + (('put', 'http://localhost:4567/api/v1/comments/518d4237b023791dca00000d/abuse_unflag'), {'data': {'api_key': 'PUT_YOUR_API_KEY_HERE', 'user_id': '1'}, 'timeout': 5}), + (('get', 'http://localhost:4567/api/v1/comments/518d4237b023791dca00000d'), {'params': {'api_key': 'PUT_YOUR_API_KEY_HERE'}, 'timeout': 5})] + + assert_equal(call_list, mock_request.call_args_list) + + assert_equal(response.status_code, 200) diff --git a/lms/djangoapps/django_comment_client/base/urls.py b/lms/djangoapps/django_comment_client/base/urls.py index 92826a18ae..41bf568012 100644 --- a/lms/djangoapps/django_comment_client/base/urls.py +++ b/lms/djangoapps/django_comment_client/base/urls.py @@ -1,8 +1,6 @@ from django.conf.urls.defaults import url, patterns -import django_comment_client.base.views - -urlpatterns = patterns('django_comment_client.base.views', +urlpatterns = patterns('django_comment_client.base.views', # nopep8 url(r'upload$', 'upload', name='upload'), url(r'users/(?P\w+)/update_moderator_status$', 'update_moderator_status', name='update_moderator_status'), url(r'threads/tags/autocomplete$', 'tags_autocomplete', name='tags_autocomplete'), @@ -11,6 +9,8 @@ urlpatterns = patterns('django_comment_client.base.views', url(r'threads/(?P[\w\-]+)/delete', 'delete_thread', name='delete_thread'), url(r'threads/(?P[\w\-]+)/upvote$', 'vote_for_thread', {'value': 'up'}, name='upvote_thread'), url(r'threads/(?P[\w\-]+)/downvote$', 'vote_for_thread', {'value': 'down'}, name='downvote_thread'), + url(r'threads/(?P[\w\-]+)/flagAbuse$', 'flag_abuse_for_thread', name='flag_abuse_for_thread'), + url(r'threads/(?P[\w\-]+)/unFlagAbuse$', 'un_flag_abuse_for_thread', name='un_flag_abuse_for_thread'), url(r'threads/(?P[\w\-]+)/unvote$', 'undo_vote_for_thread', name='undo_vote_for_thread'), url(r'threads/(?P[\w\-]+)/pin$', 'pin_thread', name='pin_thread'), url(r'threads/(?P[\w\-]+)/unpin$', 'un_pin_thread', name='un_pin_thread'), @@ -25,7 +25,8 @@ urlpatterns = patterns('django_comment_client.base.views', url(r'comments/(?P[\w\-]+)/upvote$', 'vote_for_comment', {'value': 'up'}, name='upvote_comment'), url(r'comments/(?P[\w\-]+)/downvote$', 'vote_for_comment', {'value': 'down'}, name='downvote_comment'), url(r'comments/(?P[\w\-]+)/unvote$', 'undo_vote_for_comment', name='undo_vote_for_comment'), - + url(r'comments/(?P[\w\-]+)/flagAbuse$', 'flag_abuse_for_comment', name='flag_abuse_for_comment'), + url(r'comments/(?P[\w\-]+)/unFlagAbuse$', 'un_flag_abuse_for_comment', name='un_flag_abuse_for_comment'), url(r'^(?P[\w\-.]+)/threads/create$', 'create_thread', name='create_thread'), # TODO should we search within the board? url(r'^(?P[\w\-.]+)/threads/search_similar$', 'search_similar_threads', name='search_similar_threads'), diff --git a/lms/djangoapps/django_comment_client/base/views.py b/lms/djangoapps/django_comment_client/base/views.py index 69609dcf01..34e369c1ef 100644 --- a/lms/djangoapps/django_comment_client/base/views.py +++ b/lms/djangoapps/django_comment_client/base/views.py @@ -19,14 +19,15 @@ from django.core.files.storage import get_storage_class from django.utils.translation import ugettext as _ from django.contrib.auth.models import User -from mitxmako.shortcuts import render_to_response, render_to_string -from courseware.courses import get_course_with_access +from mitxmako.shortcuts import render_to_string +from courseware.courses import get_course_with_access, get_course_by_id from course_groups.cohorts import get_cohort_id, is_commentable_cohorted from django_comment_client.utils import JsonResponse, JsonError, extract, get_courseware_context from django_comment_client.permissions import check_permissions_by_view, cached_has_permission -from django_comment_client.models import Role +from django_comment_common.models import Role +from courseware.access import has_access log = logging.getLogger(__name__) @@ -68,6 +69,10 @@ def ajax_content_response(request, course_id, content, template_name): @login_required @permitted def create_thread(request, course_id, commentable_id): + """ + Given a course and commentble ID, create the thread + """ + log.debug("Creating new thread in %r, id %r", course_id, commentable_id) course = get_course_with_access(request.user, course_id, 'load') post = request.POST @@ -119,7 +124,7 @@ def create_thread(request, course_id, commentable_id): #patch for backward compatibility to comments service if not 'pinned' in thread.attributes: thread['pinned'] = False - + if post.get('auto_subscribe', 'false').lower() == 'true': user = cc.User.from_django_user(request.user) user.follow(thread) @@ -137,6 +142,9 @@ def create_thread(request, course_id, commentable_id): @login_required @permitted def update_thread(request, course_id, thread_id): + """ + Given a course id and thread id, update a existing thread, used for both static and ajax submissions + """ thread = cc.Thread.find(thread_id) thread.update_attributes(**extract(request.POST, ['body', 'title', 'tags'])) thread.save() @@ -147,6 +155,10 @@ def update_thread(request, course_id, thread_id): def _create_comment(request, course_id, thread_id=None, parent_id=None): + """ + given a course_id, thread_id, and parent_id, create a comment, + called from create_comment to do the actual creation + """ post = request.POST comment = cc.Comment(**extract(post, ['body'])) @@ -183,6 +195,10 @@ def _create_comment(request, course_id, thread_id=None, parent_id=None): @login_required @permitted def create_comment(request, course_id, thread_id): + """ + given a course_id and thread_id, test for comment depth. if not too deep, + call _create_comment to create the actual comment. + """ if cc_settings.MAX_COMMENT_DEPTH is not None: if cc_settings.MAX_COMMENT_DEPTH < 0: return JsonError("Comment level too deep") @@ -193,6 +209,10 @@ def create_comment(request, course_id, thread_id): @login_required @permitted def delete_thread(request, course_id, thread_id): + """ + given a course_id and thread_id, delete this thread + this is ajax only + """ thread = cc.Thread.find(thread_id) thread.delete() return JsonResponse(utils.safe_content(thread.to_dict())) @@ -202,6 +222,10 @@ def delete_thread(request, course_id, thread_id): @login_required @permitted def update_comment(request, course_id, comment_id): + """ + given a course_id and comment_id, update the comment with payload attributes + handles static and ajax submissions + """ comment = cc.Comment.find(comment_id) comment.update_attributes(**extract(request.POST, ['body'])) comment.save() @@ -215,6 +239,10 @@ def update_comment(request, course_id, comment_id): @login_required @permitted def endorse_comment(request, course_id, comment_id): + """ + given a course_id and comment_id, toggle the endorsement of this comment, + ajax only + """ comment = cc.Comment.find(comment_id) comment.endorsed = request.POST.get('endorsed', 'false').lower() == 'true' comment.save() @@ -225,6 +253,10 @@ def endorse_comment(request, course_id, comment_id): @login_required @permitted def openclose_thread(request, course_id, thread_id): + """ + given a course_id and thread_id, toggle the status of this thread + ajax only + """ thread = cc.Thread.find(thread_id) thread.closed = request.POST.get('closed', 'false').lower() == 'true' thread.save() @@ -239,6 +271,10 @@ def openclose_thread(request, course_id, thread_id): @login_required @permitted def create_sub_comment(request, course_id, comment_id): + """ + given a course_id and comment_id, create a response to a comment + after checking the max depth allowed, if allowed + """ if cc_settings.MAX_COMMENT_DEPTH is not None: if cc_settings.MAX_COMMENT_DEPTH <= cc.Comment.find(comment_id).depth: return JsonError("Comment level too deep") @@ -249,6 +285,10 @@ def create_sub_comment(request, course_id, comment_id): @login_required @permitted def delete_comment(request, course_id, comment_id): + """ + given a course_id and comment_id delete this comment + ajax only + """ comment = cc.Comment.find(comment_id) comment.delete() return JsonResponse(utils.safe_content(comment.to_dict())) @@ -258,6 +298,9 @@ def delete_comment(request, course_id, comment_id): @login_required @permitted def vote_for_comment(request, course_id, comment_id, value): + """ + given a course_id and comment_id, + """ user = cc.User.from_django_user(request.user) comment = cc.Comment.find(comment_id) user.vote(comment, value) @@ -268,6 +311,10 @@ def vote_for_comment(request, course_id, comment_id, value): @login_required @permitted def undo_vote_for_comment(request, course_id, comment_id): + """ + given a course id and comment id, remove vote + ajax only + """ user = cc.User.from_django_user(request.user) comment = cc.Comment.find(comment_id) user.unvote(comment) @@ -278,34 +325,112 @@ def undo_vote_for_comment(request, course_id, comment_id): @login_required @permitted def vote_for_thread(request, course_id, thread_id, value): + """ + given a course id and thread id vote for this thread + ajax only + """ user = cc.User.from_django_user(request.user) thread = cc.Thread.find(thread_id) user.vote(thread, value) return JsonResponse(utils.safe_content(thread.to_dict())) +@require_POST +@login_required +@permitted +def flag_abuse_for_thread(request, course_id, thread_id): + """ + given a course_id and thread_id flag this thread for abuse + ajax only + """ + user = cc.User.from_django_user(request.user) + thread = cc.Thread.find(thread_id) + thread.flagAbuse(user, thread) + return JsonResponse(utils.safe_content(thread.to_dict())) + + +@require_POST +@login_required +@permitted +def un_flag_abuse_for_thread(request, course_id, thread_id): + """ + given a course id and thread id, remove abuse flag for this thread + ajax only + """ + user = cc.User.from_django_user(request.user) + course = get_course_by_id(course_id) + thread = cc.Thread.find(thread_id) + removeAll = cached_has_permission(request.user, 'openclose_thread', course_id) or has_access(request.user, course, 'staff') + thread.unFlagAbuse(user, thread, removeAll) + return JsonResponse(utils.safe_content(thread.to_dict())) + + +@require_POST +@login_required +@permitted +def flag_abuse_for_comment(request, course_id, comment_id): + """ + given a course and comment id, flag comment for abuse + ajax only + """ + user = cc.User.from_django_user(request.user) + comment = cc.Comment.find(comment_id) + comment.flagAbuse(user, comment) + return JsonResponse(utils.safe_content(comment.to_dict())) + + +@require_POST +@login_required +@permitted +def un_flag_abuse_for_comment(request, course_id, comment_id): + """ + given a course_id and comment id, unflag comment for abuse + ajax only + """ + user = cc.User.from_django_user(request.user) + course = get_course_by_id(course_id) + removeAll = cached_has_permission(request.user, 'openclose_thread', course_id) or has_access(request.user, course, 'staff') + comment = cc.Comment.find(comment_id) + comment.unFlagAbuse(user, comment, removeAll) + return JsonResponse(utils.safe_content(comment.to_dict())) + + @require_POST @login_required @permitted def undo_vote_for_thread(request, course_id, thread_id): + """ + given a course id and thread id, remove users vote for thread + ajax only + """ user = cc.User.from_django_user(request.user) thread = cc.Thread.find(thread_id) user.unvote(thread) return JsonResponse(utils.safe_content(thread.to_dict())) + @require_POST @login_required @permitted def pin_thread(request, course_id, thread_id): + """ + given a course id and thread id, pin this thread + ajax only + """ user = cc.User.from_django_user(request.user) thread = cc.Thread.find(thread_id) - thread.pin(user,thread_id) + thread.pin(user, thread_id) return JsonResponse(utils.safe_content(thread.to_dict())) + def un_pin_thread(request, course_id, thread_id): + """ + given a course id and thread id, remove pin from this thread + ajax only + """ user = cc.User.from_django_user(request.user) thread = cc.Thread.find(thread_id) - thread.un_pin(user,thread_id) + thread.un_pin(user, thread_id) return JsonResponse(utils.safe_content(thread.to_dict())) @@ -323,6 +448,10 @@ def follow_thread(request, course_id, thread_id): @login_required @permitted def follow_commentable(request, course_id, commentable_id): + """ + given a course_id and commentable id, follow this commentable + ajax only + """ user = cc.User.from_django_user(request.user) commentable = cc.Commentable.find(commentable_id) user.follow(commentable) @@ -343,6 +472,10 @@ def follow_user(request, course_id, followed_user_id): @login_required @permitted def unfollow_thread(request, course_id, thread_id): + """ + given a course id and thread id, stop following this thread + ajax only + """ user = cc.User.from_django_user(request.user) thread = cc.Thread.find(thread_id) user.unfollow(thread) @@ -353,6 +486,10 @@ def unfollow_thread(request, course_id, thread_id): @login_required @permitted def unfollow_commentable(request, course_id, commentable_id): + """ + given a course id and commentable id stop following commentable + ajax only + """ user = cc.User.from_django_user(request.user) commentable = cc.Commentable.find(commentable_id) user.unfollow(commentable) @@ -363,6 +500,10 @@ def unfollow_commentable(request, course_id, commentable_id): @login_required @permitted def unfollow_user(request, course_id, followed_user_id): + """ + given a course id and user id, stop following this user + ajax only + """ user = cc.User.from_django_user(request.user) followed_user = cc.User.find(followed_user_id) user.unfollow(followed_user) @@ -373,6 +514,10 @@ def unfollow_user(request, course_id, followed_user_id): @login_required @permitted def update_moderator_status(request, course_id, user_id): + """ + given a course id and user id, check if the user has moderator + and send back a user profile + """ is_moderator = request.POST.get('is_moderator', '').lower() if is_moderator not in ["true", "false"]: return JsonError("Must provide is_moderator as boolean value") @@ -402,6 +547,10 @@ def update_moderator_status(request, course_id, user_id): @require_GET def search_similar_threads(request, course_id, commentable_id): + """ + given a course id and commentable id, run query given in text get param + of request + """ text = request.GET.get('text', None) if text: query_params = { @@ -452,16 +601,11 @@ def upload(request, course_id): # ajax upload file to a question or answer if not file_extension in cc_settings.ALLOWED_UPLOAD_FILE_TYPES: file_types = "', '".join(cc_settings.ALLOWED_UPLOAD_FILE_TYPES) msg = _("allowed file types are '%(file_types)s'") % \ - {'file_types': file_types} + {'file_types': file_types} raise exceptions.PermissionDenied(msg) # generate new file name - new_file_name = str( - time.time() - ).replace( - '.', - str(random.randint(0, 100000)) - ) + file_extension + new_file_name = str(time.time()).replace('.', str(random.randint(0, 100000))) + file_extension file_storage = get_storage_class()() # use default storage to store file @@ -472,14 +616,14 @@ def upload(request, course_id): # ajax upload file to a question or answer if size > cc_settings.MAX_UPLOAD_FILE_SIZE: file_storage.delete(new_file_name) msg = _("maximum upload file size is %(file_size)sK") % \ - {'file_size': cc_settings.MAX_UPLOAD_FILE_SIZE} + {'file_size': cc_settings.MAX_UPLOAD_FILE_SIZE} raise exceptions.PermissionDenied(msg) - except exceptions.PermissionDenied, e: + except exceptions.PermissionDenied, err: error = unicode(e) - except Exception, e: - print e - logging.critical(unicode(e)) + except Exception, err: + print err + logging.critical(unicode(err)) error = _('Error uploading file. Please contact the site administrator. Thank you.') if error == '': diff --git a/lms/djangoapps/django_comment_client/forum/urls.py b/lms/djangoapps/django_comment_client/forum/urls.py index 1e676dee87..863267fde9 100644 --- a/lms/djangoapps/django_comment_client/forum/urls.py +++ b/lms/djangoapps/django_comment_client/forum/urls.py @@ -1,7 +1,6 @@ from django.conf.urls.defaults import url, patterns -import django_comment_client.forum.views -urlpatterns = patterns('django_comment_client.forum.views', +urlpatterns = patterns('django_comment_client.forum.views', # nopep8 url(r'users/(?P\w+)/followed$', 'followed_threads', name='followed_threads'), url(r'users/(?P\w+)$', 'user_profile', name='user_profile'), url(r'^(?P[\w\-.]+)/threads/(?P\w+)$', 'single_thread', name='single_thread'), diff --git a/lms/djangoapps/django_comment_client/forum/views.py b/lms/djangoapps/django_comment_client/forum/views.py index 3a517af26e..b04bd787d8 100644 --- a/lms/djangoapps/django_comment_client/forum/views.py +++ b/lms/djangoapps/django_comment_client/forum/views.py @@ -7,9 +7,9 @@ from django.http import Http404 from django.core.context_processors import csrf from django.contrib.auth.models import User -from mitxmako.shortcuts import render_to_response, render_to_string +from mitxmako.shortcuts import render_to_response from courseware.courses import get_course_with_access -from course_groups.cohorts import (is_course_cohorted, get_cohort_id, is_commentable_cohorted, +from course_groups.cohorts import (is_course_cohorted, get_cohort_id, is_commentable_cohorted, get_cohorted_commentables, get_course_cohorts, get_cohort_by_id) from courseware.access import has_access @@ -79,7 +79,7 @@ def get_threads(request, course_id, discussion_id=None, per_page=THREADS_PER_PAG strip_none(extract(request.GET, ['page', 'sort_key', 'sort_order', 'text', - 'tags', 'commentable_ids']))) + 'tags', 'commentable_ids', 'flagged']))) threads, page, num_pages = cc.Thread.search(query_params) @@ -92,7 +92,7 @@ def get_threads(request, course_id, discussion_id=None, per_page=THREADS_PER_PAG else: thread['group_name'] = "" thread['group_string'] = "This post visible to everyone." - + #patch for backward compatibility to comments service if not 'pinned' in thread: thread['pinned'] = False @@ -108,7 +108,6 @@ def inline_discussion(request, course_id, discussion_id): """ Renders JSON for DiscussionModules """ - course = get_course_with_access(request.user, course_id, 'load') try: @@ -175,6 +174,9 @@ def forum_form_discussion(request, course_id): try: unsafethreads, query_params = get_threads(request, course_id) # This might process a search query threads = [utils.safe_content(thread) for thread in unsafethreads] + except (cc.utils.CommentClientMaintenanceError) as err: + log.warning("Forum is in maintenance mode") + return render_to_response('discussion/maintenance.html', {}) except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError) as err: log.error("Error loading forum discussion threads: %s" % str(err)) raise Http404 @@ -219,6 +221,7 @@ def forum_form_discussion(request, course_id): 'threads': saxutils.escape(json.dumps(threads), escapedict), 'thread_pages': query_params['num_pages'], 'user_info': saxutils.escape(json.dumps(user_info), escapedict), + 'flag_moderator': cached_has_permission(request.user, 'openclose_thread', course.id) or has_access(request.user, course, 'staff'), 'annotated_content_info': saxutils.escape(json.dumps(annotated_content_info), escapedict), 'course_id': course.id, 'category_map': category_map, @@ -230,7 +233,6 @@ def forum_form_discussion(request, course_id): 'is_course_cohorted': is_course_cohorted(course_id) } # print "start rendering.." - return render_to_response('discussion/index.html', context) @@ -242,19 +244,12 @@ def single_thread(request, course_id, discussion_id, thread_id): try: thread = cc.Thread.find(thread_id).retrieve(recursive=True, user_id=request.user.id) - - #patch for backward compatibility with comments service - if not 'pinned' in thread.attributes: - thread['pinned'] = False - except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError) as err: log.error("Error loading single thread.") raise Http404 if request.is_ajax(): - courseware_context = get_courseware_context(thread, course) - annotated_content_info = utils.get_annotated_content_infos(course_id, thread, request.user, user_info=user_info) context = {'thread': thread.to_dict(), 'course_id': course_id} # TODO: Remove completely or switch back to server side rendering @@ -326,6 +321,7 @@ def single_thread(request, course_id, discussion_id, thread_id): 'thread_pages': query_params['num_pages'], 'is_course_cohorted': is_course_cohorted(course_id), 'is_moderator': cached_has_permission(request.user, "see_all_cohorts", course_id), + 'flag_moderator': cached_has_permission(request.user, 'openclose_thread', course.id) or has_access(request.user, course, 'staff'), 'cohorts': cohorts, 'user_cohort': get_cohort_id(request.user, course_id), 'cohorted_commentables': cohorted_commentables @@ -401,7 +397,7 @@ def followed_threads(request, course_id, user_id): 'discussion_data': map(utils.safe_content, threads), 'page': query_params['page'], 'num_pages': query_params['num_pages'], - }) + }) else: context = { diff --git a/lms/djangoapps/django_comment_client/management/commands/assign_role.py b/lms/djangoapps/django_comment_client/management/commands/assign_role.py index 655631008f..4e9321410c 100644 --- a/lms/djangoapps/django_comment_client/management/commands/assign_role.py +++ b/lms/djangoapps/django_comment_client/management/commands/assign_role.py @@ -1,7 +1,7 @@ from optparse import make_option from django.core.management.base import BaseCommand, CommandError -from django_comment_client.models import Role +from django_comment_common.models import Role from django.contrib.auth.models import User @@ -12,7 +12,7 @@ class Command(BaseCommand): dest='remove', default=False, help='Remove the role instead of adding it'), - ) + ) args = ' ' help = 'Assign a discussion forum role to a user ' diff --git a/lms/djangoapps/django_comment_client/management/commands/assign_roles_for_course.py b/lms/djangoapps/django_comment_client/management/commands/assign_roles_for_course.py index 72100738d9..9ef4f3d0b1 100644 --- a/lms/djangoapps/django_comment_client/management/commands/assign_roles_for_course.py +++ b/lms/djangoapps/django_comment_client/management/commands/assign_roles_for_course.py @@ -7,7 +7,7 @@ Enrollments. from django.core.management.base import BaseCommand, CommandError from student.models import CourseEnrollment -from django_comment_client.models import assign_default_role +from django_comment_common.models import assign_default_role class Command(BaseCommand): diff --git a/lms/djangoapps/django_comment_client/management/commands/create_roles_for_existing.py b/lms/djangoapps/django_comment_client/management/commands/create_roles_for_existing.py index d5ba0042fc..037bb292ec 100644 --- a/lms/djangoapps/django_comment_client/management/commands/create_roles_for_existing.py +++ b/lms/djangoapps/django_comment_client/management/commands/create_roles_for_existing.py @@ -7,7 +7,7 @@ Enrollments. from django.core.management.base import BaseCommand, CommandError from student.models import CourseEnrollment -from django_comment_client.models import assign_default_role +from django_comment_common.models import assign_default_role class Command(BaseCommand): diff --git a/lms/djangoapps/django_comment_client/management/commands/reload_forum_users.py b/lms/djangoapps/django_comment_client/management/commands/reload_forum_users.py index 5e7e268270..53d76cda8f 100644 --- a/lms/djangoapps/django_comment_client/management/commands/reload_forum_users.py +++ b/lms/djangoapps/django_comment_client/management/commands/reload_forum_users.py @@ -1,15 +1,16 @@ """ Reload forum (comment client) users from existing users. """ -from django.core.management.base import BaseCommand, CommandError +from django.core.management.base import BaseCommand from django.contrib.auth.models import User import comment_client as cc + class Command(BaseCommand): help = 'Reload forum (comment client) users from existing users' - def adduser(self,user): + def adduser(self, user): print user try: cc_user = cc.User.from_django_user(user) @@ -22,8 +23,6 @@ class Command(BaseCommand): uset = [User.objects.get(username=x) for x in args] else: uset = User.objects.all() - + for user in uset: self.adduser(user) - - \ No newline at end of file diff --git a/lms/djangoapps/django_comment_client/management/commands/seed_permissions_roles.py b/lms/djangoapps/django_comment_client/management/commands/seed_permissions_roles.py index 6a31e73af3..1073d7dbcf 100644 --- a/lms/djangoapps/django_comment_client/management/commands/seed_permissions_roles.py +++ b/lms/djangoapps/django_comment_client/management/commands/seed_permissions_roles.py @@ -1,5 +1,5 @@ from django.core.management.base import BaseCommand, CommandError -from django_comment_client.models import Permission, Role +from django_comment_common.utils import seed_permissions_roles class Command(BaseCommand): @@ -12,26 +12,5 @@ class Command(BaseCommand): if len(args) > 1: raise CommandError("Too many arguments") course_id = args[0] - administrator_role = Role.objects.get_or_create(name="Administrator", course_id=course_id)[0] - moderator_role = Role.objects.get_or_create(name="Moderator", course_id=course_id)[0] - community_ta_role = Role.objects.get_or_create(name="Community TA", course_id=course_id)[0] - student_role = Role.objects.get_or_create(name="Student", course_id=course_id)[0] - for per in ["vote", "update_thread", "follow_thread", "unfollow_thread", - "update_comment", "create_sub_comment", "unvote", "create_thread", - "follow_commentable", "unfollow_commentable", "create_comment", ]: - student_role.add_permission(per) - - for per in ["edit_content", "delete_thread", "openclose_thread", - "endorse_comment", "delete_comment", "see_all_cohorts"]: - moderator_role.add_permission(per) - - for per in ["manage_moderator"]: - administrator_role.add_permission(per) - - moderator_role.inherit_permissions(student_role) - - # For now, Community TA == Moderator, except for the styling. - community_ta_role.inherit_permissions(moderator_role) - - administrator_role.inherit_permissions(moderator_role) + seed_permissions_roles(course_id) diff --git a/lms/djangoapps/django_comment_client/management/commands/show_permissions.py b/lms/djangoapps/django_comment_client/management/commands/show_permissions.py index ec3167aa0c..67fc29ea97 100644 --- a/lms/djangoapps/django_comment_client/management/commands/show_permissions.py +++ b/lms/djangoapps/django_comment_client/management/commands/show_permissions.py @@ -1,5 +1,5 @@ from django.core.management.base import BaseCommand, CommandError -from django_comment_client.models import Permission, Role +from django_comment_common.models import Permission, Role from django.contrib.auth.models import User diff --git a/lms/djangoapps/django_comment_client/models.py b/lms/djangoapps/django_comment_client/models.py index 023b355a29..76d27be3bf 100644 --- a/lms/djangoapps/django_comment_client/models.py +++ b/lms/djangoapps/django_comment_client/models.py @@ -1,64 +1 @@ -import logging - -from django.db import models -from django.contrib.auth.models import User - -from django.dispatch import receiver -from django.db.models.signals import post_save - -from student.models import CourseEnrollment - -from courseware.courses import get_course_by_id - -FORUM_ROLE_ADMINISTRATOR = 'Administrator' -FORUM_ROLE_MODERATOR = 'Moderator' -FORUM_ROLE_COMMUNITY_TA = 'Community TA' -FORUM_ROLE_STUDENT = 'Student' - - -@receiver(post_save, sender=CourseEnrollment) -def assign_default_role(sender, instance, **kwargs): - if instance.user.is_staff: - role = Role.objects.get_or_create(course_id=instance.course_id, name="Moderator")[0] - else: - role = Role.objects.get_or_create(course_id=instance.course_id, name="Student")[0] - - logging.info("assign_default_role: adding %s as %s" % (instance.user, role)) - instance.user.roles.add(role) - - -class Role(models.Model): - name = models.CharField(max_length=30, null=False, blank=False) - users = models.ManyToManyField(User, related_name="roles") - course_id = models.CharField(max_length=255, blank=True, db_index=True) - - def __unicode__(self): - return self.name + " for " + (self.course_id if self.course_id else "all courses") - - def inherit_permissions(self, role): # TODO the name of this method is a little bit confusing, - # since it's one-off and doesn't handle inheritance later - if role.course_id and role.course_id != self.course_id: - logging.warning("{0} cannot inherit permissions from {1} due to course_id inconsistency", \ - self, role) - for per in role.permissions.all(): - self.add_permission(per) - - def add_permission(self, permission): - self.permissions.add(Permission.objects.get_or_create(name=permission)[0]) - - def has_permission(self, permission): - course = get_course_by_id(self.course_id) - if self.name == FORUM_ROLE_STUDENT and \ - (permission.startswith('edit') or permission.startswith('update') or permission.startswith('create')) and \ - (not course.forum_posts_allowed): - return False - - return self.permissions.filter(name=permission).exists() - - -class Permission(models.Model): - name = models.CharField(max_length=30, null=False, blank=False, primary_key=True) - roles = models.ManyToManyField(Role, related_name="permissions") - - def __unicode__(self): - return self.name +# This file is intentionally blank. It has been moved to common/djangoapps/django_comment_common diff --git a/lms/djangoapps/django_comment_client/permissions.py b/lms/djangoapps/django_comment_client/permissions.py index 7d21cc9783..1a523a170a 100644 --- a/lms/djangoapps/django_comment_client/permissions.py +++ b/lms/djangoapps/django_comment_client/permissions.py @@ -1,4 +1,4 @@ -from .models import Role, Permission +from django_comment_common.models import Role, Permission from django.db.models.signals import post_save from django.dispatch import receiver from student.models import CourseEnrollment @@ -73,7 +73,6 @@ def check_conditions_permissions(user, permissions, course_id, **kwargs): return True in results elif operator == "and": return not False in results - return test(user, permissions, operator="or") @@ -89,6 +88,10 @@ VIEW_PERMISSIONS = { 'vote_for_comment' : [['vote', 'is_open']], 'undo_vote_for_comment': [['unvote', 'is_open']], 'vote_for_thread' : [['vote', 'is_open']], + 'flag_abuse_for_thread': [['vote', 'is_open']], + 'un_flag_abuse_for_thread': [['vote', 'is_open']], + 'flag_abuse_for_comment': [['vote', 'is_open']], + 'un_flag_abuse_for_comment': [['vote', 'is_open']], 'undo_vote_for_thread': [['unvote', 'is_open']], 'pin_thread': ['create_comment'], 'un_pin_thread': ['create_comment'], diff --git a/lms/djangoapps/django_comment_client/tests.py b/lms/djangoapps/django_comment_client/tests.py index a35df54cd9..8fd8ed7e2b 100644 --- a/lms/djangoapps/django_comment_client/tests.py +++ b/lms/djangoapps/django_comment_client/tests.py @@ -6,7 +6,7 @@ from django.test import TestCase from student.models import CourseEnrollment from django_comment_client.permissions import has_permission -from django_comment_client.models import Role +from django_comment_common.models import Role class PermissionsTestCase(TestCase): @@ -21,9 +21,9 @@ class PermissionsTestCase(TestCase): self.student_role = Role.objects.get_or_create(name="Student", course_id=self.course_id)[0] self.student = User.objects.create(username=self.random_str(), - password="123456", email="john@yahoo.com") + password="123456", email="john@yahoo.com") self.moderator = User.objects.create(username=self.random_str(), - password="123456", email="staff@edx.org") + password="123456", email="staff@edx.org") self.moderator.is_staff = True self.moderator.save() self.student_enrollment = CourseEnrollment.objects.create(user=self.student, course_id=self.course_id) diff --git a/lms/djangoapps/django_comment_client/tests/factories.py b/lms/djangoapps/django_comment_client/tests/factories.py new file mode 100644 index 0000000000..4a82c8f1bb --- /dev/null +++ b/lms/djangoapps/django_comment_client/tests/factories.py @@ -0,0 +1,13 @@ +from factory import DjangoModelFactory +from django_comment_common.models import Role, Permission + + +class RoleFactory(DjangoModelFactory): + FACTORY_FOR = Role + name = 'Student' + course_id = 'edX/toy/2012_Fall' + + +class PermissionFactory(DjangoModelFactory): + FACTORY_FOR = Permission + name = 'create_comment' diff --git a/lms/djangoapps/django_comment_client/tests/mock_cs_server/__init__.py b/lms/djangoapps/django_comment_client/tests/mock_cs_server/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/django_comment_client/tests/mock_cs_server/mock_cs_server.py b/lms/djangoapps/django_comment_client/tests/mock_cs_server/mock_cs_server.py new file mode 100644 index 0000000000..367485effb --- /dev/null +++ b/lms/djangoapps/django_comment_client/tests/mock_cs_server/mock_cs_server.py @@ -0,0 +1,111 @@ +from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler +import json +from logging import getLogger +logger = getLogger(__name__) + + +class MockCommentServiceRequestHandler(BaseHTTPRequestHandler): + ''' + A handler for Comment Service POST requests. + ''' + protocol = "HTTP/1.0" + + def do_POST(self): + ''' + Handle a POST request from the client + Used by the APIs for comment threads, commentables, comments, + subscriptions, commentables, users + ''' + # Retrieve the POST data into a dict. + # It should have been sent in json format + length = int(self.headers.getheader('content-length')) + data_string = self.rfile.read(length) + post_dict = json.loads(data_string) + + # Log the request + logger.debug("Comment Service received POST request %s to path %s" % + (json.dumps(post_dict), self.path)) + + # Every good post has at least an API key + if 'api_key' in post_dict: + response = self.server._response_str + # Log the response + logger.debug("Comment Service: sending response %s" % json.dumps(response)) + + # Send a response back to the client + self.send_response(200) + self.send_header('Content-type', 'application/json') + self.end_headers() + self.wfile.write(response) + + else: + # Respond with failure + self.send_response(500, 'Bad Request: does not contain API key') + self.send_header('Content-type', 'text/plain') + self.end_headers() + return False + + def do_PUT(self): + ''' + Handle a PUT request from the client + Used by the APIs for comment threads, commentables, comments, + subscriptions, commentables, users + ''' + # Retrieve the PUT data into a dict. + # It should have been sent in json format + length = int(self.headers.getheader('content-length')) + data_string = self.rfile.read(length) + post_dict = json.loads(data_string) + + # Log the request + logger.debug("Comment Service received PUT request %s to path %s" % + (json.dumps(post_dict), self.path)) + + # Every good post has at least an API key + if 'api_key' in post_dict: + response = self.server._response_str + # Log the response + logger.debug("Comment Service: sending response %s" % json.dumps(response)) + + # Send a response back to the client + self.send_response(200) + self.send_header('Content-type', 'application/json') + self.end_headers() + self.wfile.write(response) + + else: + # Respond with failure + self.send_response(500, 'Bad Request: does not contain API key') + self.send_header('Content-type', 'text/plain') + self.end_headers() + return False + + +class MockCommentServiceServer(HTTPServer): + ''' + A mock Comment Service server that responds + to POST requests to localhost. + ''' + def __init__(self, port_num, + response={'username': 'new', 'external_id': 1}): + ''' + Initialize the mock Comment Service server instance. + *port_num* is the localhost port to listen to + *response* is a dictionary that will be JSON-serialized + and sent in response to comment service requests. + ''' + self._response_str = json.dumps(response) + + handler = MockCommentServiceRequestHandler + address = ('', port_num) + HTTPServer.__init__(self, address, handler) + + def shutdown(self): + ''' + Stop the server and free up the port + ''' + # First call superclass shutdown() + HTTPServer.shutdown(self) + + # We also need to manually close the socket + self.socket.close() diff --git a/lms/djangoapps/django_comment_client/tests/mock_cs_server/test_mock_cs_server.py b/lms/djangoapps/django_comment_client/tests/mock_cs_server/test_mock_cs_server.py new file mode 100644 index 0000000000..9b1977cbff --- /dev/null +++ b/lms/djangoapps/django_comment_client/tests/mock_cs_server/test_mock_cs_server.py @@ -0,0 +1,59 @@ +import unittest +import threading +import json +import urllib2 +from mock_cs_server import MockCommentServiceServer +from nose.plugins.skip import SkipTest + + +class MockCommentServiceServerTest(unittest.TestCase): + ''' + A mock version of the Comment Service server that listens on a local + port and responds with pre-defined grade messages. + ''' + + def setUp(self): + # This is a test of the test setup, + # so it does not need to run as part of the unit test suite + # You can re-enable it by commenting out the line below + raise SkipTest + + # Create the server + server_port = 4567 + self.server_url = 'http://127.0.0.1:%d' % server_port + + # Start up the server and tell it that by default it should + # return this as its json response + self.expected_response = {'username': 'user100', 'external_id': '4'} + self.server = MockCommentServiceServer(port_num=server_port, + response=self.expected_response) + + # Start the server in a separate daemon thread + server_thread = threading.Thread(target=self.server.serve_forever) + server_thread.daemon = True + server_thread.start() + + def tearDown(self): + # Stop the server, freeing up the port + self.server.shutdown() + + def test_new_user_request(self): + """ + Test the mock comment service using an example + of how you would create a new user + """ + # Send a request + values = {'username': u'user100', 'api_key': 'TEST_API_KEY', + 'external_id': '4', 'email': u'user100@edx.org'} + data = json.dumps(values) + headers = {'Content-Type': 'application/json', 'Content-Length': len(data)} + req = urllib2.Request(self.server_url + '/api/v1/users/4', data, headers) + + # Send the request to the mock cs server + response = urllib2.urlopen(req) + + # Receive the reply from the mock cs server + response_dict = json.loads(response.read()) + + # You should have received the response specified in the setup above + self.assertEqual(response_dict, self.expected_response) diff --git a/lms/djangoapps/django_comment_client/tests/test_helpers.py b/lms/djangoapps/django_comment_client/tests/test_helpers.py index e2c074231f..6ca9680052 100644 --- a/lms/djangoapps/django_comment_client/tests/test_helpers.py +++ b/lms/djangoapps/django_comment_client/tests/test_helpers.py @@ -1,7 +1,3 @@ -import string -import random -import collections - from django.test import TestCase from django_comment_client.helpers import pluralize diff --git a/lms/djangoapps/django_comment_client/tests/test_models.py b/lms/djangoapps/django_comment_client/tests/test_models.py index 6f90b3c4b8..e45c883931 100644 --- a/lms/djangoapps/django_comment_client/tests/test_models.py +++ b/lms/djangoapps/django_comment_client/tests/test_models.py @@ -1,4 +1,4 @@ -import django_comment_client.models as models +import django_comment_common.models as models import django_comment_client.permissions as permissions from django.test import TestCase @@ -9,24 +9,20 @@ class RoleClassTestCase(TestCase): # because xmodel.course_module.id_to_location looks for a string to split self.course_id = "edX/toy/2012_Fall" - self.student_role = models.Role.objects.get_or_create(name="Student", \ - course_id=self.course_id)[0] + self.student_role = models.Role.objects.get_or_create(name="Student", + course_id=self.course_id)[0] self.student_role.add_permission("delete_thread") - self.student_2_role = models.Role.objects.get_or_create(name="Student", \ + self.student_2_role = models.Role.objects.get_or_create(name="Student", + course_id=self.course_id)[0] + self.TA_role = models.Role.objects.get_or_create(name="Community TA", course_id=self.course_id)[0] - self.TA_role = models.Role.objects.get_or_create(name="Community TA",\ - course_id=self.course_id)[0] self.course_id_2 = "edx/6.002x/2012_Fall" - self.TA_role_2 = models.Role.objects.get_or_create(name="Community TA",\ - course_id=self.course_id_2)[0] + self.TA_role_2 = models.Role.objects.get_or_create(name="Community TA", + course_id=self.course_id_2)[0] + class Dummy(): def render_template(): pass - d = {"data": { - "textbooks": [], - 'wiki_slug': True, - } - } def testHasPermission(self): # Whenever you add a permission to student_role, @@ -47,7 +43,6 @@ class RoleClassTestCase(TestCase): class PermissionClassTestCase(TestCase): - def setUp(self): self.permission = permissions.Permission.objects.get_or_create(name="test")[0] diff --git a/lms/djangoapps/django_comment_client/tests/test_mustache_helpers.py b/lms/djangoapps/django_comment_client/tests/test_mustache_helpers.py index 7db3ba6e86..b6b0cbe188 100644 --- a/lms/djangoapps/django_comment_client/tests/test_mustache_helpers.py +++ b/lms/djangoapps/django_comment_client/tests/test_mustache_helpers.py @@ -1,19 +1,8 @@ -import string -import random -import collections - from django.test import TestCase -from mock import MagicMock -from django.test.utils import override_settings -import django.core.urlresolvers as urlresolvers - import django_comment_client.mustache_helpers as mustache_helpers -######################################################################################### - class PluralizeTest(TestCase): - def setUp(self): self.text1 = '0 goat' self.text2 = '1 goat' @@ -25,11 +14,8 @@ class PluralizeTest(TestCase): self.assertEqual(mustache_helpers.pluralize(self.content, self.text2), 'goat') self.assertEqual(mustache_helpers.pluralize(self.content, self.text3), 'goats') -######################################################################################### - class CloseThreadTextTest(TestCase): - def setUp(self): self.contentClosed = {'closed': True} self.contentOpen = {'closed': False} @@ -37,6 +23,3 @@ class CloseThreadTextTest(TestCase): def test_close_thread_text(self): self.assertEqual(mustache_helpers.close_thread_text(self.contentClosed), 'Re-open thread') self.assertEqual(mustache_helpers.close_thread_text(self.contentOpen), 'Close thread') - -######################################################################################### - diff --git a/lms/djangoapps/django_comment_client/tests/test_utils.py b/lms/djangoapps/django_comment_client/tests/test_utils.py index cec006e630..555264cb5f 100644 --- a/lms/djangoapps/django_comment_client/tests/test_utils.py +++ b/lms/djangoapps/django_comment_client/tests/test_utils.py @@ -1,45 +1,9 @@ -import string -import random -import collections - from django.test import TestCase - -import factory -from django.contrib.auth.models import User -from student.models import UserProfile, CourseEnrollment -from django_comment_client.models import Role, Permission - -import django_comment_client.models as models +from student.tests.factories import UserFactory, CourseEnrollmentFactory +from django_comment_common.models import Role, Permission +from factories import RoleFactory import django_comment_client.utils as utils -import xmodule.modulestore.django as django - - -class UserFactory(factory.Factory): - FACTORY_FOR = User - username = 'robot' - password = '123456' - email = 'robot@edx.org' - is_active = True - is_staff = False - - -class CourseEnrollmentFactory(factory.Factory): - FACTORY_FOR = CourseEnrollment - user = factory.SubFactory(UserFactory) - course_id = 'edX/toy/2012_Fall' - - -class RoleFactory(factory.Factory): - FACTORY_FOR = Role - name = 'Student' - course_id = 'edX/toy/2012_Fall' - - -class PermissionFactory(factory.Factory): - FACTORY_FOR = Permission - name = 'create_comment' - class DictionaryTestCase(TestCase): def test_extract(self): diff --git a/lms/djangoapps/django_comment_client/urls.py b/lms/djangoapps/django_comment_client/urls.py index a9fc86c363..98700da4ab 100644 --- a/lms/djangoapps/django_comment_client/urls.py +++ b/lms/djangoapps/django_comment_client/urls.py @@ -1,6 +1,6 @@ from django.conf.urls.defaults import url, patterns, include -urlpatterns = patterns('', +urlpatterns = patterns('', # nopep8 url(r'forum/?', include('django_comment_client.forum.urls')), url(r'', include('django_comment_client.base.urls')), ) diff --git a/lms/djangoapps/django_comment_client/utils.py b/lms/djangoapps/django_comment_client/utils.py index 42233b84da..276956f0e9 100644 --- a/lms/djangoapps/django_comment_client/utils.py +++ b/lms/djangoapps/django_comment_client/utils.py @@ -1,3 +1,4 @@ +import time from collections import defaultdict import logging import time @@ -13,7 +14,7 @@ from django.core.urlresolvers import reverse from django.db import connection from django.http import HttpResponse from django.utils import simplejson -from django_comment_client.models import Role +from django_comment_common.models import Role from django_comment_client.permissions import check_permissions_by_view from xmodule.modulestore.exceptions import NoPathToItem @@ -104,12 +105,12 @@ def filter_unstarted_categories(category_map): result_map = {} unfiltered_queue = [category_map] - filtered_queue = [result_map] + filtered_queue = [result_map] while len(unfiltered_queue) > 0: unfiltered_map = unfiltered_queue.pop() - filtered_map = filtered_queue.pop() + filtered_map = filtered_queue.pop() filtered_map["children"] = [] filtered_map["entries"] = {} @@ -146,28 +147,16 @@ def sort_map_entries(category_map): def initialize_discussion_info(course): - global _DISCUSSIONINFO - # only cache in-memory discussion information for 10 minutes - # this is because we need a short-term hack fix for - # mongo-backed courseware whereby new discussion modules can be added - # without LMS service restart - - if _DISCUSSIONINFO[course.id]: - timestamp = _DISCUSSIONINFO[course.id].get('timestamp', datetime.now()) - age = datetime.now() - timestamp - # expire every 5 minutes - if age.seconds < 300: - return - course_id = course.id discussion_id_map = {} unexpanded_category_map = defaultdict(list) # get all discussion models within this course_id - all_modules = modulestore().get_items(['i4x', course.location.org, course.location.course, 'discussion', None], course_id=course_id) + all_modules = modulestore().get_items(['i4x', course.location.org, course.location.course, + 'discussion', None], course_id=course_id) for module in all_modules: skip_module = False @@ -186,8 +175,7 @@ def initialize_discussion_info(course): category = " / ".join([x.strip() for x in category.split("/")]) last_category = category.split("/")[-1] discussion_id_map[id] = {"location": module.location, "title": last_category + " / " + title} - unexpanded_category_map[category].append({"title": title, "id": id, - "sort_key": sort_key, "start_date": module.lms.start}) + unexpanded_category_map[category].append({"title": title, "id": id, "sort_key": sort_key, "start_date": module.lms.start}) category_map = {"entries": defaultdict(dict), "subcategories": defaultdict(dict)} for category_path, entries in unexpanded_category_map.items(): @@ -214,9 +202,9 @@ def initialize_discussion_info(course): level = path[-1] if level not in node: node[level] = {"subcategories": defaultdict(dict), - "entries": defaultdict(dict), - "sort_key": level, - "start_date": category_start_date} + "entries": defaultdict(dict), + "sort_key": level, + "start_date": category_start_date} else: if node[level]["start_date"] > category_start_date: node[level]["start_date"] = category_start_date @@ -296,12 +284,12 @@ class QueryCountDebugMiddleware(object): def get_ability(course_id, content, user): return { - 'editable': check_permissions_by_view(user, course_id, content, "update_thread" if content['type'] == 'thread' else "update_comment"), - 'can_reply': check_permissions_by_view(user, course_id, content, "create_comment" if content['type'] == 'thread' else "create_sub_comment"), - 'can_endorse': check_permissions_by_view(user, course_id, content, "endorse_comment") if content['type'] == 'comment' else False, - 'can_delete': check_permissions_by_view(user, course_id, content, "delete_thread" if content['type'] == 'thread' else "delete_comment"), - 'can_openclose': check_permissions_by_view(user, course_id, content, "openclose_thread") if content['type'] == 'thread' else False, - 'can_vote': check_permissions_by_view(user, course_id, content, "vote_for_thread" if content['type'] == 'thread' else "vote_for_comment"), + 'editable': check_permissions_by_view(user, course_id, content, "update_thread" if content['type'] == 'thread' else "update_comment"), + 'can_reply': check_permissions_by_view(user, course_id, content, "create_comment" if content['type'] == 'thread' else "create_sub_comment"), + 'can_endorse': check_permissions_by_view(user, course_id, content, "endorse_comment") if content['type'] == 'comment' else False, + 'can_delete': check_permissions_by_view(user, course_id, content, "delete_thread" if content['type'] == 'thread' else "delete_comment"), + 'can_openclose': check_permissions_by_view(user, course_id, content, "openclose_thread") if content['type'] == 'thread' else False, + 'can_vote': check_permissions_by_view(user, course_id, content, "vote_for_thread" if content['type'] == 'thread' else "vote_for_comment"), } #TODO: RENAME @@ -330,6 +318,7 @@ def get_annotated_content_infos(course_id, thread, user, user_info): Get metadata for a thread and its children """ infos = {} + def annotate(content): infos[str(content['id'])] = get_annotated_content_info(course_id, content, user, user_info) for child in content.get('children', []): @@ -394,8 +383,8 @@ def get_courseware_context(content, course): location = id_map[id]["location"].url() title = id_map[id]["title"] - url = reverse('jump_to', kwargs={"course_id":course.location.course_id, - "location": location}) + url = reverse('jump_to', kwargs={"course_id": course.location.course_id, + "location": location}) content_info = {"courseware_url": url, "courseware_title": title} return content_info @@ -408,7 +397,8 @@ def safe_content(content): 'updated_at', 'depth', 'type', 'commentable_id', 'comments_count', 'at_position_list', 'children', 'highlighted_title', 'highlighted_body', 'courseware_title', 'courseware_url', 'tags', 'unread_comments_count', - 'read', 'group_id', 'group_name', 'group_string', 'pinned' + 'read', 'group_id', 'group_name', 'group_string', 'pinned', 'abuse_flaggers' + ] if (content.get('anonymous') is False) and (content.get('anonymous_to_peers') is False): diff --git a/lms/djangoapps/instructor/tests/__init__.py b/lms/djangoapps/instructor/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/instructor/tests/test_download_csv.py b/lms/djangoapps/instructor/tests/test_download_csv.py new file mode 100644 index 0000000000..29e18eee4d --- /dev/null +++ b/lms/djangoapps/instructor/tests/test_download_csv.py @@ -0,0 +1,78 @@ +""" +Unit tests for instructor dashboard + +Based on (and depends on) unit tests for courseware. + +Notes for running by hand: + +django-admin.py test --settings=lms.envs.test --pythonpath=. lms/djangoapps/instructor +""" + +from django.test.utils import override_settings + +# Need access to internal func to put users in the right group +from django.contrib.auth.models import Group + +from django.core.urlresolvers import reverse + +from courseware.access import _course_staff_group_name +from courseware.tests.tests import LoginEnrollmentTestCase, TEST_DATA_XML_MODULESTORE, get_user +from xmodule.modulestore.django import modulestore +import xmodule.modulestore.django + + +@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) +class TestInstructorDashboardGradeDownloadCSV(LoginEnrollmentTestCase): + ''' + Check for download of csv + ''' + + def setUp(self): + xmodule.modulestore.django._MODULESTORES = {} + + self.full = modulestore().get_course("edX/full/6.002_Spring_2012") + self.toy = modulestore().get_course("edX/toy/2012_Fall") + + # Create two accounts + self.student = 'view@test.com' + self.instructor = 'view2@test.com' + self.password = 'foo' + self.create_account('u1', self.student, self.password) + self.create_account('u2', self.instructor, self.password) + self.activate_user(self.student) + self.activate_user(self.instructor) + + def make_instructor(course): + group_name = _course_staff_group_name(course.location) + g = Group.objects.create(name=group_name) + g.user_set.add(get_user(self.instructor)) + + make_instructor(self.toy) + + self.logout() + self.login(self.instructor, self.password) + self.enroll(self.toy) + + def test_download_grades_csv(self): + course = self.toy + url = reverse('instructor_dashboard', kwargs={'course_id': course.id}) + msg = "url = {0}\n".format(url) + response = self.client.post(url, {'action': 'Download CSV of all student grades for this course'}) + msg += "instructor dashboard download csv grades: response = '{0}'\n".format(response) + + self.assertEqual(response['Content-Type'], 'text/csv', msg) + + cdisp = response['Content-Disposition'] + msg += "Content-Disposition = '%s'\n" % cdisp + self.assertEqual(cdisp, 'attachment; filename=grades_{0}.csv'.format(course.id), msg) + + body = response.content.replace('\r', '') + msg += "body = '{0}'\n".format(body) + + # All the not-actually-in-the-course hw and labs come from the + # default grading policy string in graders.py + expected_body = '''"ID","Username","Full Name","edX email","External email","HW 01","HW 02","HW 03","HW 04","HW 05","HW 06","HW 07","HW 08","HW 09","HW 10","HW 11","HW 12","HW Avg","Lab 01","Lab 02","Lab 03","Lab 04","Lab 05","Lab 06","Lab 07","Lab 08","Lab 09","Lab 10","Lab 11","Lab 12","Lab Avg","Midterm","Final" +"2","u2","Fred Weasley","view2@test.com","","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0" +''' + + self.assertEqual(body, expected_body, msg) diff --git a/lms/djangoapps/instructor/tests.py b/lms/djangoapps/instructor/tests/test_forum_admin.py similarity index 73% rename from lms/djangoapps/instructor/tests.py rename to lms/djangoapps/instructor/tests/test_forum_admin.py index fd8e652997..7b4e729867 100644 --- a/lms/djangoapps/instructor/tests.py +++ b/lms/djangoapps/instructor/tests/test_forum_admin.py @@ -1,20 +1,15 @@ """ -Unit tests for instructor dashboard - -Based on (and depends on) unit tests for courseware. - -Notes for running by hand: - -django-admin.py test --settings=lms.envs.test --pythonpath=. lms/djangoapps/instructor +Unit tests for instructor dashboard forum administration """ + from django.test.utils import override_settings # Need access to internal func to put users in the right group from django.contrib.auth.models import Group from django.core.urlresolvers import reverse -from django_comment_client.models import Role, FORUM_ROLE_ADMINISTRATOR, \ +from django_comment_common.models import Role, FORUM_ROLE_ADMINISTRATOR, \ FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_STUDENT from django_comment_client.utils import has_forum_access @@ -24,63 +19,6 @@ from xmodule.modulestore.django import modulestore import xmodule.modulestore.django -@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) -class TestInstructorDashboardGradeDownloadCSV(LoginEnrollmentTestCase): - ''' - Check for download of csv - ''' - - def setUp(self): - xmodule.modulestore.django._MODULESTORES = {} - - self.full = modulestore().get_course("edX/full/6.002_Spring_2012") - self.toy = modulestore().get_course("edX/toy/2012_Fall") - - # Create two accounts - self.student = 'view@test.com' - self.instructor = 'view2@test.com' - self.password = 'foo' - self.create_account('u1', self.student, self.password) - self.create_account('u2', self.instructor, self.password) - self.activate_user(self.student) - self.activate_user(self.instructor) - - def make_instructor(course): - group_name = _course_staff_group_name(course.location) - g = Group.objects.create(name=group_name) - g.user_set.add(get_user(self.instructor)) - - make_instructor(self.toy) - - self.logout() - self.login(self.instructor, self.password) - self.enroll(self.toy) - - def test_download_grades_csv(self): - course = self.toy - url = reverse('instructor_dashboard', kwargs={'course_id': course.id}) - msg = "url = {0}\n".format(url) - response = self.client.post(url, {'action': 'Download CSV of all student grades for this course'}) - msg += "instructor dashboard download csv grades: response = '{0}'\n".format(response) - - self.assertEqual(response['Content-Type'], 'text/csv', msg) - - cdisp = response['Content-Disposition'] - msg += "Content-Disposition = '%s'\n" % cdisp - self.assertEqual(cdisp, 'attachment; filename=grades_{0}.csv'.format(course.id), msg) - - body = response.content.replace('\r', '') - msg += "body = '{0}'\n".format(body) - - # All the not-actually-in-the-course hw and labs come from the - # default grading policy string in graders.py - expected_body = '''"ID","Username","Full Name","edX email","External email","HW 01","HW 02","HW 03","HW 04","HW 05","HW 06","HW 07","HW 08","HW 09","HW 10","HW 11","HW 12","HW Avg","Lab 01","Lab 02","Lab 03","Lab 04","Lab 05","Lab 06","Lab 07","Lab 08","Lab 09","Lab 10","Lab 11","Lab 12","Lab Avg","Midterm","Final" -"2","u2","Fred Weasley","view2@test.com","","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0" -''' - - self.assertEqual(body, expected_body, msg) - - FORUM_ROLES = [FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA] FORUM_ADMIN_ACTION_SUFFIX = {FORUM_ROLE_ADMINISTRATOR: 'admin', FORUM_ROLE_MODERATOR: 'moderator', FORUM_ROLE_COMMUNITY_TA: 'community TA'} FORUM_ADMIN_USER = {FORUM_ROLE_ADMINISTRATOR: 'forumadmin', FORUM_ROLE_MODERATOR: 'forummoderator', FORUM_ROLE_COMMUNITY_TA: 'forummoderator'} @@ -208,4 +146,4 @@ class TestInstructorDashboardForumAdmin(LoginEnrollmentTestCase): added_roles.append(rolename) added_roles.sort() roles = ', '.join(added_roles) - self.assertTrue(response.content.find('{0}'.format(roles)) >= 0, 'not finding roles "{0}"'.format(roles)) + self.assertTrue(response.content.find('{0}'.format(roles)) >= 0, 'not finding roles "{0}"'.format(roles)) \ No newline at end of file diff --git a/lms/djangoapps/instructor/tests/test_gradebook.py b/lms/djangoapps/instructor/tests/test_gradebook.py new file mode 100644 index 0000000000..4b1d22b594 --- /dev/null +++ b/lms/djangoapps/instructor/tests/test_gradebook.py @@ -0,0 +1,153 @@ +""" +Tests of the instructor dashboard gradebook +""" + +from django.test import TestCase +from django.test.utils import override_settings +from django.core.urlresolvers import reverse +from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory +from student.tests.factories import UserFactory, CourseEnrollmentFactory, UserProfileFactory, AdminFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from mock import patch, DEFAULT +from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE +from capa.tests.response_xml_factory import StringResponseXMLFactory +from courseware.tests.factories import StudentModuleFactory +from xmodule.modulestore import Location +from xmodule.modulestore.django import modulestore + + +USER_COUNT = 11 + +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +class TestGradebook(ModuleStoreTestCase): + grading_policy = None + + def setUp(self): + instructor = AdminFactory.create() + self.client.login(username=instructor.username, password='test') + + modulestore().request_cache = modulestore().metadata_inheritance_cache_subsystem = None + + course_data = {} + if self.grading_policy is not None: + course_data['grading_policy'] = self.grading_policy + + self.course = CourseFactory.create(data=course_data) + chapter = ItemFactory.create( + parent_location=self.course.location, + template="i4x://edx/templates/sequential/Empty", + ) + section = ItemFactory.create( + parent_location=chapter.location, + template="i4x://edx/templates/sequential/Empty", + metadata={'graded': True, 'format': 'Homework'} + ) + + self.users = [ + UserFactory.create(username='robot%d' % i, email='robot+test+%d@edx.org' % i) + for i in xrange(USER_COUNT) + ] + + for user in self.users: + CourseEnrollmentFactory.create(user=user, course_id=self.course.id) + + for i in xrange(USER_COUNT-1): + template_name = "i4x://edx/templates/problem/Blank_Common_Problem" + item = ItemFactory.create( + parent_location=section.location, + template=template_name, + data=StringResponseXMLFactory().build_xml(answer='foo'), + metadata={'rerandomize': 'always'} + ) + + for j, user in enumerate(self.users): + StudentModuleFactory.create( + grade=1 if i < j else 0, + max_grade=1, + student=user, + course_id=self.course.id, + module_state_key=Location(item.location).url() + ) + + self.response = self.client.get(reverse('gradebook', args=(self.course.id,))) + + def test_response_code(self): + self.assertEquals(self.response.status_code, 200) + +class TestDefaultGradingPolicy(TestGradebook): + def test_all_users_listed(self): + for user in self.users: + self.assertIn(user.username, self.response.content) + + def test_default_policy(self): + # Default >= 50% passes, so Users 5-10 should be passing for Homework 1 [6] + # One use at the top of the page [1] + self.assertEquals(7, self.response.content.count('grade_Pass')) + + # Users 1-5 attempted Homework 1 (and get Fs) [4] + # Users 1-10 attempted any homework (and get Fs) [10] + # Users 4-10 scored enough to not get rounded to 0 for the class (and get Fs) [7] + # One use at top of the page [1] + self.assertEquals(22, self.response.content.count('grade_F')) + + # All other grades are None [29 categories * 11 users - 27 non-empty grades = 292] + # One use at the top of the page [1] + self.assertEquals(293, self.response.content.count('grade_None')) + +class TestLetterCutoffPolicy(TestGradebook): + grading_policy = { + "GRADER": [ + { + "type": "Homework", + "min_count": 1, + "drop_count": 0, + "short_label": "HW", + "weight": 1 + }, + ], + "GRADE_CUTOFFS": { + 'A': .9, + 'B': .8, + 'C': .7, + 'D': .6, + } + } + + def test_styles(self): + + self.assertIn("grade_A {color:green;}", self.response.content) + self.assertIn("grade_B {color:Chocolate;}", self.response.content) + self.assertIn("grade_C {color:DarkSlateGray;}", self.response.content) + self.assertIn("grade_D {color:DarkSlateGray;}", self.response.content) + + def test_assigned_grades(self): + print self.response.content + # Users 9-10 have >= 90% on Homeworks [2] + # Users 9-10 have >= 90% on the class [2] + # One use at the top of the page [1] + self.assertEquals(5, self.response.content.count('grade_A')) + + # User 8 has 80 <= Homeworks < 90 [1] + # User 8 has 80 <= class < 90 [1] + # One use at the top of the page [1] + self.assertEquals(3, self.response.content.count('grade_B')) + + # User 7 has 70 <= Homeworks < 80 [1] + # User 7 has 70 <= class < 80 [1] + # One use at the top of the page [1] + self.assertEquals(3, self.response.content.count('grade_C')) + + # User 6 has 60 <= Homeworks < 70 [1] + # User 6 has 60 <= class < 70 [1] + # One use at the top of the page [1] + self.assertEquals(3, self.response.content.count('grade_C')) + + # Users 1-5 have 60% > grades > 0 on Homeworks [5] + # Users 1-5 have 60% > grades > 0 on the class [5] + # One use at top of the page [1] + self.assertEquals(11, self.response.content.count('grade_F')) + + # User 0 has 0 on Homeworks [1] + # User 0 has 0 on the class [1] + # One use at the top of the page [1] + self.assertEquals(3, self.response.content.count('grade_None')) diff --git a/lms/djangoapps/instructor/views.py b/lms/djangoapps/instructor/views.py index a3b4f42bf7..00b1b918b3 100644 --- a/lms/djangoapps/instructor/views.py +++ b/lms/djangoapps/instructor/views.py @@ -27,7 +27,7 @@ from courseware.access import (has_access, get_access_group_name, course_beta_test_group_name) from courseware.courses import get_course_with_access from courseware.models import StudentModule -from django_comment_client.models import (Role, +from django_comment_common.models import (Role, FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA) @@ -961,11 +961,14 @@ def gradebook(request, course_id): } for student in enrolled_students] - return render_to_response('courseware/gradebook.html', {'students': student_info, - 'course': course, - 'course_id': course_id, - # Checked above - 'staff_access': True, }) + return render_to_response('courseware/gradebook.html', { + 'students': student_info, + 'course': course, + 'course_id': course_id, + # Checked above + 'staff_access': True, + 'ordered_grades': sorted(course.grade_cutoffs.items(), key=lambda i: i[1], reverse=True), + }) @cache_control(no_cache=True, no_store=True, must_revalidate=True) diff --git a/lms/djangoapps/licenses/models.py b/lms/djangoapps/licenses/models.py index 06f777f611..db24126a8e 100644 --- a/lms/djangoapps/licenses/models.py +++ b/lms/djangoapps/licenses/models.py @@ -73,7 +73,7 @@ def _create_license(user, software): license.save() except IndexError: # there are no free licenses - log.error('No serial numbers available for {0}', software) + log.error('No serial numbers available for %s', software) license = None # TODO [rocha]look if someone has unenrolled from the class # and already has a serial number diff --git a/lms/djangoapps/licenses/tests.py b/lms/djangoapps/licenses/tests.py index 5289c31bc6..151a0faa9d 100644 --- a/lms/djangoapps/licenses/tests.py +++ b/lms/djangoapps/licenses/tests.py @@ -5,13 +5,21 @@ import json from uuid import uuid4 from random import shuffle from tempfile import NamedTemporaryFile -from factory import Factory, SubFactory +from factory import DjangoModelFactory, SubFactory from django.test import TestCase +from django.test.client import Client +from django.test.utils import override_settings from django.core.management import call_command from django.core.urlresolvers import reverse +from nose.tools import assert_true + +from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE from licenses.models import CourseSoftware, UserLicense -from courseware.tests.tests import LoginEnrollmentTestCase, get_user + +from student.tests.factories import UserFactory +from xmodule.modulestore.tests.factories import CourseFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase COURSE_1 = 'edX/toy/2012_Fall' @@ -23,7 +31,7 @@ SERIAL_1 = '123456abcde' log = logging.getLogger(__name__) -class CourseSoftwareFactory(Factory): +class CourseSoftwareFactory(DjangoModelFactory): '''Factory for generating CourseSoftware objects in database''' FACTORY_FOR = CourseSoftware @@ -33,7 +41,7 @@ class CourseSoftwareFactory(Factory): course_id = COURSE_1 -class UserLicenseFactory(Factory): +class UserLicenseFactory(DjangoModelFactory): ''' Factory for generating UserLicense objects in database @@ -42,19 +50,24 @@ class UserLicenseFactory(Factory): ''' FACTORY_FOR = UserLicense + user = None software = SubFactory(CourseSoftwareFactory) serial = SERIAL_1 -class LicenseTestCase(LoginEnrollmentTestCase): +class LicenseTestCase(TestCase): '''Tests for licenses.views''' def setUp(self): '''creates a user and logs in''' - self.setup_viewtest_user() + # self.setup_viewtest_user() + self.user = UserFactory(username='test', + email='test@edx.org', password='test_password') + self.client = Client() + assert_true(self.client.login(username='test', password='test_password')) self.software = CourseSoftwareFactory() def test_get_license(self): - UserLicenseFactory(user=get_user(self.viewtest_email), software=self.software) + UserLicenseFactory(user=self.user, software=self.software) response = self.client.post(reverse('user_software_license'), {'software': SOFTWARE_1, 'generate': 'false'}, HTTP_X_REQUESTED_WITH='XMLHttpRequest', @@ -121,7 +134,7 @@ class LicenseTestCase(LoginEnrollmentTestCase): self.assertEqual(404, response.status_code) def test_get_license_without_login(self): - self.logout() + self.client.logout() response = self.client.post(reverse('user_software_license'), {'software': SOFTWARE_1, 'generate': 'false'}, HTTP_X_REQUESTED_WITH='XMLHttpRequest', @@ -130,20 +143,24 @@ class LicenseTestCase(LoginEnrollmentTestCase): self.assertEqual(302, response.status_code) -class CommandTest(TestCase): +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +class CommandTest(ModuleStoreTestCase): '''Test management command for importing serial numbers''' + def setUp(self): + course = CourseFactory.create() + self.course_id = course.id def test_import_serial_numbers(self): size = 20 log.debug('Adding one set of serials for {0}'.format(SOFTWARE_1)) with generate_serials_file(size) as temp_file: - args = [COURSE_1, SOFTWARE_1, temp_file.name] + args = [self.course_id, SOFTWARE_1, temp_file.name] call_command('import_serial_numbers', *args) log.debug('Adding one set of serials for {0}'.format(SOFTWARE_2)) with generate_serials_file(size) as temp_file: - args = [COURSE_1, SOFTWARE_2, temp_file.name] + args = [self.course_id, SOFTWARE_2, temp_file.name] call_command('import_serial_numbers', *args) log.debug('There should be only 2 course-software entries') @@ -156,7 +173,7 @@ class CommandTest(TestCase): log.debug('Adding more serial numbers to {0}'.format(SOFTWARE_1)) with generate_serials_file(size) as temp_file: - args = [COURSE_1, SOFTWARE_1, temp_file.name] + args = [self.course_id, SOFTWARE_1, temp_file.name] call_command('import_serial_numbers', *args) log.debug('There should be still only 2 course-software entries') @@ -179,7 +196,7 @@ class CommandTest(TestCase): with NamedTemporaryFile() as tmpfile: tmpfile.write('\n'.join(known_serials)) tmpfile.flush() - args = [COURSE_1, SOFTWARE_1, tmpfile.name] + args = [self.course_id, SOFTWARE_1, tmpfile.name] call_command('import_serial_numbers', *args) log.debug('Check if we added only the new ones') diff --git a/lms/djangoapps/notes/README.md b/lms/djangoapps/notes/README.md new file mode 100644 index 0000000000..2e81fa5ec1 --- /dev/null +++ b/lms/djangoapps/notes/README.md @@ -0,0 +1,57 @@ +Notes Django App +================ + +This is a django application that stores and displays notes that students make while reading static HTML book(s) in their courseware. Note taking functionality in the static HTML book(s) is handled by a wrapper script around [annotator.js](http://okfnlabs.org/annotator/), which interfaces with the API provided by this application to store and retrieve notes. + +Usage +----- + +To use this application, course staff must opt-in by doing the following: + +* Login to [Studio](http://studio.edx.org/). +* Go to *Course Settings* -> *Advanced Settings* +* Find the ```advanced_modules``` policy key and in the policy value field, add ```"notes"``` to the list. +* Save the course settings. + +The result of following these steps is that you should see a new tab appear in the courseware named *My Notes*. This will display a journal of notes that the student has created in the static HTML book(s). Second, when you highlight text in the static HTML book(s), a dialog will appear. You can enter some notes and tags and save it. The note will appear highlighted in the text and will also be saved to the journal. + +To disable the *My Notes* tab and notes in the static HTML book(s), simply reverse the above steps (i.e. remove ```"notes"``` from the ```advanced_modules``` policy setting). + +### Caveats and Limitations + +* Notes are private to each student. +* Sharing and replying to notes is not supported. +* The student *My Notes* interface is very limited. +* There is no instructor interface to view student notes. + +Developer Overview +------------------ + +### Quickstart + +``` +$ rake django-admin[syncdb] +$ rake django-admin[migrate] +``` + +Then follow the steps above to enable the *My Notes* tab or manually add a tab to the policy tab configuration with ```{"type": "notes", "name": "My Notes"}```. + +### App Directory Structure: + +lms/djangoapps/notes: + +* api.py - API used by annotator.js on the frontend +* models.py - Contains note model for storing notes +* tests.py - Unit tests +* views.py - View to display the journal of notes (i.e. *My Notes* tab) +* urls.py - Maps the API and View routes. +* utils.py - Contains method for checking if the course has this app enabled. Intended to be public to other modules. + +Also requires: + +* lms/static/coffee/src/notes.coffee -- wrapper around annotator.js +* lms/templates/notes.html -- used by views.py to display the notes + +Interacts with: + +* lms/djangoapps/staticbook - the html static book checks to see if notes is enabled and has some logic to enable/disable accordingly diff --git a/lms/djangoapps/notes/__init__.py b/lms/djangoapps/notes/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/notes/api.py b/lms/djangoapps/notes/api.py new file mode 100644 index 0000000000..1162a144c0 --- /dev/null +++ b/lms/djangoapps/notes/api.py @@ -0,0 +1,251 @@ +from django.contrib.auth.decorators import login_required +from django.http import HttpResponse, Http404 +from django.core.exceptions import ValidationError + +from notes.models import Note +from notes.utils import notes_enabled_for_course +from courseware.courses import get_course_with_access + +import json +import logging +import collections + +log = logging.getLogger(__name__) + +API_SETTINGS = { + 'META': {'name': 'Notes API', 'version': 1}, + + # Maps resources to HTTP methods and actions + 'RESOURCE_MAP': { + 'root': {'GET': 'root'}, + 'notes': {'GET': 'index', 'POST': 'create'}, + 'note': {'GET': 'read', 'PUT': 'update', 'DELETE': 'delete'}, + 'search': {'GET': 'search'}, + }, + + # Cap the number of notes that can be returned in one request + 'MAX_NOTE_LIMIT': 1000, +} + +# Wrapper class for HTTP response and data. All API actions are expected to return this. +ApiResponse = collections.namedtuple('ApiResponse', ['http_response', 'data']) + +#----------------------------------------------------------------------# +# API requests are routed through api_request() using the resource map. + + +def api_enabled(request, course_id): + ''' + Returns True if the api is enabled for the course, otherwise False. + ''' + course = _get_course(request, course_id) + return notes_enabled_for_course(course) + + +@login_required +def api_request(request, course_id, **kwargs): + ''' + Routes API requests to the appropriate action method and returns JSON. + Raises a 404 if the requested resource does not exist or notes are + disabled for the course. + ''' + + # Verify that the api should be accessible to this course + if not api_enabled(request, course_id): + log.debug('Notes are disabled for course: {0}'.format(course_id)) + raise Http404 + + # Locate the requested resource + resource_map = API_SETTINGS.get('RESOURCE_MAP', {}) + resource_name = kwargs.pop('resource') + resource_method = request.method + resource = resource_map.get(resource_name) + + if resource is None: + log.debug('Resource "{0}" does not exist'.format(resource_name)) + raise Http404 + + if resource_method not in resource.keys(): + log.debug('Resource "{0}" does not support method "{1}"'.format(resource_name, resource_method)) + raise Http404 + + # Execute the action associated with the resource + func = resource.get(resource_method) + module = globals() + if func not in module: + log.debug('Function "{0}" does not exist for request {1} {2}'.format(func, resource_method, resource_name)) + raise Http404 + + log.debug('API request: {0} {1}'.format(resource_method, resource_name)) + + api_response = module[func](request, course_id, **kwargs) + http_response = api_format(api_response) + + return http_response + + +def api_format(api_response): + ''' + Takes an ApiResponse and returns an HttpResponse. + ''' + http_response = api_response.http_response + content_type = 'application/json' + content = '' + + # not doing a strict boolean check on data becuase it could be an empty list + if api_response.data is not None and api_response.data != '': + content = json.dumps(api_response.data) + + http_response['Content-type'] = content_type + http_response.content = content + + log.debug('API response type: {0} content: {1}'.format(content_type, content)) + + return http_response + + +def _get_course(request, course_id): + ''' + Helper function to load and return a user's course. + ''' + return get_course_with_access(request.user, course_id, 'load') + +#----------------------------------------------------------------------# +# API actions exposed via the resource map. + + +def index(request, course_id): + ''' + Returns a list of annotation objects. + ''' + MAX_LIMIT = API_SETTINGS.get('MAX_NOTE_LIMIT') + + notes = Note.objects.order_by('id').filter(course_id=course_id, + user=request.user)[:MAX_LIMIT] + + return ApiResponse(http_response=HttpResponse(), data=[note.as_dict() for note in notes]) + + +def create(request, course_id): + ''' + Receives an annotation object to create and returns a 303 with the read location. + ''' + note = Note(course_id=course_id, user=request.user) + + try: + note.clean(request.body) + except ValidationError as e: + log.debug(e) + return ApiResponse(http_response=HttpResponse('', status=400), data=None) + + note.save() + response = HttpResponse('', status=303) + response['Location'] = note.get_absolute_url() + + return ApiResponse(http_response=response, data=None) + + +def read(request, course_id, note_id): + ''' + Returns a single annotation object. + ''' + try: + note = Note.objects.get(id=note_id) + except Note.DoesNotExist: + return ApiResponse(http_response=HttpResponse('', status=404), data=None) + + if note.user.id != request.user.id: + return ApiResponse(http_response=HttpResponse('', status=403), data=None) + + return ApiResponse(http_response=HttpResponse(), data=note.as_dict()) + + +def update(request, course_id, note_id): + ''' + Updates an annotation object and returns a 303 with the read location. + ''' + try: + note = Note.objects.get(id=note_id) + except Note.DoesNotExist: + return ApiResponse(http_response=HttpResponse('', status=404), data=None) + + if note.user.id != request.user.id: + return ApiResponse(http_response=HttpResponse('', status=403), data=None) + + try: + note.clean(request.body) + except ValidationError as e: + log.debug(e) + return ApiResponse(http_response=HttpResponse('', status=400), data=None) + + note.save() + + response = HttpResponse('', status=303) + response['Location'] = note.get_absolute_url() + + return ApiResponse(http_response=response, data=None) + + +def delete(request, course_id, note_id): + ''' + Deletes the annotation object and returns a 204 with no content. + ''' + try: + note = Note.objects.get(id=note_id) + except Note.DoesNotExist: + return ApiResponse(http_response=HttpResponse('', status=404), data=None) + + if note.user.id != request.user.id: + return ApiResponse(http_response=HttpResponse('', status=403), data=None) + + note.delete() + + return ApiResponse(http_response=HttpResponse('', status=204), data=None) + + +def search(request, course_id): + ''' + Returns a subset of annotation objects based on a search query. + ''' + MAX_LIMIT = API_SETTINGS.get('MAX_NOTE_LIMIT') + + # search parameters + offset = request.GET.get('offset', '') + limit = request.GET.get('limit', '') + uri = request.GET.get('uri', '') + + # validate search parameters + if offset.isdigit(): + offset = int(offset) + else: + offset = 0 + + if limit.isdigit(): + limit = int(limit) + if limit == 0 or limit > MAX_LIMIT: + limit = MAX_LIMIT + else: + limit = MAX_LIMIT + + # set filters + filters = {'course_id': course_id, 'user': request.user} + if uri != '': + filters['uri'] = uri + + # retrieve notes + notes = Note.objects.order_by('id').filter(**filters) + total = notes.count() + rows = notes[offset:offset + limit] + result = { + 'total': total, + 'rows': [note.as_dict() for note in rows] + } + + return ApiResponse(http_response=HttpResponse(), data=result) + + +def root(request, course_id): + ''' + Returns version information about the API. + ''' + return ApiResponse(http_response=HttpResponse(), data=API_SETTINGS.get('META')) diff --git a/lms/djangoapps/notes/migrations/0001_initial.py b/lms/djangoapps/notes/migrations/0001_initial.py new file mode 100644 index 0000000000..1629b2355d --- /dev/null +++ b/lms/djangoapps/notes/migrations/0001_initial.py @@ -0,0 +1,90 @@ +# -*- 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 'Note' + db.create_table('notes_note', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])), + ('course_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)), + ('uri', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)), + ('text', self.gf('django.db.models.fields.TextField')(default='')), + ('quote', self.gf('django.db.models.fields.TextField')(default='')), + ('range_start', self.gf('django.db.models.fields.CharField')(max_length=2048)), + ('range_start_offset', self.gf('django.db.models.fields.IntegerField')()), + ('range_end', self.gf('django.db.models.fields.CharField')(max_length=2048)), + ('range_end_offset', self.gf('django.db.models.fields.IntegerField')()), + ('tags', self.gf('django.db.models.fields.TextField')(default='')), + ('created', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, null=True, db_index=True, blank=True)), + ('updated', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, db_index=True, blank=True)), + )) + db.send_create_signal('notes', ['Note']) + + + def backwards(self, orm): + # Deleting model 'Note' + db.delete_table('notes_note') + + + 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'}) + }, + 'notes.note': { + 'Meta': {'object_name': 'Note'}, + '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'}), + 'quote': ('django.db.models.fields.TextField', [], {'default': "''"}), + 'range_end': ('django.db.models.fields.CharField', [], {'max_length': '2048'}), + 'range_end_offset': ('django.db.models.fields.IntegerField', [], {}), + 'range_start': ('django.db.models.fields.CharField', [], {'max_length': '2048'}), + 'range_start_offset': ('django.db.models.fields.IntegerField', [], {}), + 'tags': ('django.db.models.fields.TextField', [], {'default': "''"}), + 'text': ('django.db.models.fields.TextField', [], {'default': "''"}), + 'updated': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'uri': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + } + } + + complete_apps = ['notes'] \ No newline at end of file diff --git a/lms/djangoapps/notes/migrations/__init__.py b/lms/djangoapps/notes/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/notes/models.py b/lms/djangoapps/notes/models.py new file mode 100644 index 0000000000..aa2ec7a377 --- /dev/null +++ b/lms/djangoapps/notes/models.py @@ -0,0 +1,81 @@ +from django.db import models +from django.contrib.auth.models import User +from django.core.urlresolvers import reverse +from django.core.exceptions import ValidationError +from django.utils.html import strip_tags +import json + + +class Note(models.Model): + user = models.ForeignKey(User, db_index=True) + course_id = models.CharField(max_length=255, db_index=True) + uri = models.CharField(max_length=255, db_index=True) + text = models.TextField(default="") + quote = models.TextField(default="") + range_start = models.CharField(max_length=2048) # xpath string + range_start_offset = models.IntegerField() + range_end = models.CharField(max_length=2048) # xpath string + range_end_offset = models.IntegerField() + tags = models.TextField(default="") # comma-separated string + created = models.DateTimeField(auto_now_add=True, null=True, db_index=True) + updated = models.DateTimeField(auto_now=True, db_index=True) + + def clean(self, json_body): + """ + Cleans the note object or raises a ValidationError. + """ + if json_body is None: + raise ValidationError('Note must have a body.') + + body = json.loads(json_body) + if not type(body) is dict: + raise ValidationError('Note body must be a dictionary.') + + # NOTE: all three of these fields should be considered user input + # and may be output back to the user, so we need to sanitize them. + # These fields should only contain _plain text_. + self.uri = strip_tags(body.get('uri', '')) + self.text = strip_tags(body.get('text', '')) + self.quote = strip_tags(body.get('quote', '')) + + ranges = body.get('ranges') + if ranges is None or len(ranges) != 1: + raise ValidationError('Note must contain exactly one range.') + + self.range_start = ranges[0]['start'] + self.range_start_offset = ranges[0]['startOffset'] + self.range_end = ranges[0]['end'] + self.range_end_offset = ranges[0]['endOffset'] + + self.tags = "" + tags = [strip_tags(tag) for tag in body.get('tags', [])] + if len(tags) > 0: + self.tags = ",".join(tags) + + def get_absolute_url(self): + """ + Returns the absolute url for the note object. + """ + kwargs = {'course_id': self.course_id, 'note_id': str(self.pk)} + return reverse('notes_api_note', kwargs=kwargs) + + def as_dict(self): + """ + Returns the note object as a dictionary. + """ + return { + 'id': self.pk, + 'user_id': self.user.pk, + 'uri': self.uri, + 'text': self.text, + 'quote': self.quote, + 'ranges': [{ + 'start': self.range_start, + 'startOffset': self.range_start_offset, + 'end': self.range_end, + 'endOffset': self.range_end_offset + }], + 'tags': self.tags.split(","), + 'created': str(self.created), + 'updated': str(self.updated) + } diff --git a/lms/djangoapps/notes/tests.py b/lms/djangoapps/notes/tests.py new file mode 100644 index 0000000000..a7609b91ac --- /dev/null +++ b/lms/djangoapps/notes/tests.py @@ -0,0 +1,398 @@ +""" +Unit tests for the notes app. +""" + +from django.test import TestCase +from django.test.client import Client +from django.core.urlresolvers import reverse +from django.contrib.auth.models import User +from django.core.exceptions import ValidationError + +import collections +import unittest +import json +import logging + +from . import utils, api, models + + +class UtilsTest(TestCase): + def setUp(self): + ''' + Setup a dummy course-like object with a tabs field that can be + accessed via attribute lookup. + ''' + self.course = collections.namedtuple('DummyCourse', ['tabs']) + self.course.tabs = [] + + def test_notes_not_enabled(self): + ''' + Tests that notes are disabled when the course tab configuration does NOT + contain a tab with type "notes." + ''' + self.assertFalse(utils.notes_enabled_for_course(self.course)) + + def test_notes_enabled(self): + ''' + Tests that notes are enabled when the course tab configuration contains + a tab with type "notes." + ''' + self.course.tabs = [{'type': 'foo'}, + {'name': 'My Notes', 'type': 'notes'}, + {'type': 'bar'}] + + self.assertTrue(utils.notes_enabled_for_course(self.course)) + + +class ApiTest(TestCase): + + def setUp(self): + self.client = Client() + + # Mocks + api.api_enabled = self.mock_api_enabled(True) + + # Create two accounts + self.password = 'abc' + self.student = User.objects.create_user('student', 'student@test.com', self.password) + self.student2 = User.objects.create_user('student2', 'student2@test.com', self.password) + self.instructor = User.objects.create_user('instructor', 'instructor@test.com', self.password) + self.course_id = 'HarvardX/CB22x/The_Ancient_Greek_Hero' + self.note = { + 'user': self.student, + 'course_id': self.course_id, + 'uri': '/', + 'text': 'foo', + 'quote': 'bar', + 'range_start': 0, + 'range_start_offset': 0, + 'range_end': 100, + 'range_end_offset': 0, + 'tags': 'a,b,c' + } + + # Make sure no note with this ID ever exists for testing purposes + self.NOTE_ID_DOES_NOT_EXIST = 99999 + + def mock_api_enabled(self, is_enabled): + return (lambda request, course_id: is_enabled) + + def login(self, as_student=None): + username = None + password = self.password + + if as_student is None: + username = self.student.username + else: + username = as_student.username + + self.client.login(username=username, password=password) + + def url(self, name, args={}): + args.update({'course_id': self.course_id}) + return reverse(name, kwargs=args) + + def create_notes(self, num_notes, create=True): + notes = [] + for n in range(num_notes): + note = models.Note(**self.note) + if create: + note.save() + notes.append(note) + return notes + + def test_root(self): + self.login() + + resp = self.client.get(self.url('notes_api_root')) + self.assertEqual(resp.status_code, 200) + self.assertNotEqual(resp.content, '') + + content = json.loads(resp.content) + + self.assertEqual(set(('name', 'version')), set(content.keys())) + self.assertIsInstance(content['version'], int) + self.assertEqual(content['name'], 'Notes API') + + def test_index_empty(self): + self.login() + + resp = self.client.get(self.url('notes_api_notes')) + self.assertEqual(resp.status_code, 200) + self.assertNotEqual(resp.content, '') + + content = json.loads(resp.content) + self.assertEqual(len(content), 0) + + def test_index_with_notes(self): + num_notes = 3 + self.login() + self.create_notes(num_notes) + + resp = self.client.get(self.url('notes_api_notes')) + self.assertEqual(resp.status_code, 200) + self.assertNotEqual(resp.content, '') + + content = json.loads(resp.content) + self.assertIsInstance(content, list) + self.assertEqual(len(content), num_notes) + + def test_index_max_notes(self): + self.login() + + MAX_LIMIT = api.API_SETTINGS.get('MAX_NOTE_LIMIT') + num_notes = MAX_LIMIT + 1 + self.create_notes(num_notes) + + resp = self.client.get(self.url('notes_api_notes')) + self.assertEqual(resp.status_code, 200) + self.assertNotEqual(resp.content, '') + + content = json.loads(resp.content) + self.assertIsInstance(content, list) + self.assertEqual(len(content), MAX_LIMIT) + + def test_create_note(self): + self.login() + + notes = self.create_notes(1) + self.assertEqual(len(notes), 1) + + note_dict = notes[0].as_dict() + excluded_fields = ['id', 'user_id', 'created', 'updated'] + note = dict([(k, v) for k, v in note_dict.items() if k not in excluded_fields]) + + resp = self.client.post(self.url('notes_api_notes'), + json.dumps(note), + content_type='application/json', + HTTP_X_REQUESTED_WITH='XMLHttpRequest') + + self.assertEqual(resp.status_code, 303) + self.assertEqual(len(resp.content), 0) + + def test_create_empty_notes(self): + self.login() + + for empty_test in [None, [], '']: + resp = self.client.post(self.url('notes_api_notes'), + json.dumps(empty_test), + content_type='application/json', + HTTP_X_REQUESTED_WITH='XMLHttpRequest') + self.assertEqual(resp.status_code, 400) + + def test_create_note_missing_ranges(self): + self.login() + + notes = self.create_notes(1) + self.assertEqual(len(notes), 1) + note_dict = notes[0].as_dict() + + excluded_fields = ['id', 'user_id', 'created', 'updated'] + ['ranges'] + note = dict([(k, v) for k, v in note_dict.items() if k not in excluded_fields]) + + resp = self.client.post(self.url('notes_api_notes'), + json.dumps(note), + content_type='application/json', + HTTP_X_REQUESTED_WITH='XMLHttpRequest') + self.assertEqual(resp.status_code, 400) + + def test_read_note(self): + self.login() + + notes = self.create_notes(3) + self.assertEqual(len(notes), 3) + + for note in notes: + resp = self.client.get(self.url('notes_api_note', {'note_id': note.pk})) + self.assertEqual(resp.status_code, 200) + self.assertNotEqual(resp.content, '') + + content = json.loads(resp.content) + self.assertEqual(content['id'], note.pk) + self.assertEqual(content['user_id'], note.user_id) + + def test_note_doesnt_exist_to_read(self): + self.login() + resp = self.client.get(self.url('notes_api_note', { + 'note_id': self.NOTE_ID_DOES_NOT_EXIST + })) + self.assertEqual(resp.status_code, 404) + self.assertEqual(resp.content, '') + + def test_student_doesnt_have_permission_to_read_note(self): + notes = self.create_notes(1) + self.assertEqual(len(notes), 1) + note = notes[0] + + # set the student id to a different student (not the one that created the notes) + self.login(as_student=self.student2) + resp = self.client.get(self.url('notes_api_note', {'note_id': note.pk})) + self.assertEqual(resp.status_code, 403) + self.assertEqual(resp.content, '') + + def test_delete_note(self): + self.login() + + notes = self.create_notes(1) + self.assertEqual(len(notes), 1) + note = notes[0] + + resp = self.client.delete(self.url('notes_api_note', { + 'note_id': note.pk + })) + self.assertEqual(resp.status_code, 204) + self.assertEqual(resp.content, '') + + with self.assertRaises(models.Note.DoesNotExist): + models.Note.objects.get(pk=note.pk) + + def test_note_does_not_exist_to_delete(self): + self.login() + + resp = self.client.delete(self.url('notes_api_note', { + 'note_id': self.NOTE_ID_DOES_NOT_EXIST + })) + self.assertEqual(resp.status_code, 404) + self.assertEqual(resp.content, '') + + def test_student_doesnt_have_permission_to_delete_note(self): + notes = self.create_notes(1) + self.assertEqual(len(notes), 1) + note = notes[0] + + self.login(as_student=self.student2) + resp = self.client.delete(self.url('notes_api_note', { + 'note_id': note.pk + })) + self.assertEqual(resp.status_code, 403) + self.assertEqual(resp.content, '') + + try: + models.Note.objects.get(pk=note.pk) + except models.Note.DoesNotExist: + self.fail('note should exist and not be deleted because the student does not have permission to do so') + + def test_update_note(self): + notes = self.create_notes(1) + note = notes[0] + + updated_dict = note.as_dict() + updated_dict.update({ + 'text': 'itchy and scratchy', + 'tags': ['simpsons', 'cartoons', 'animation'] + }) + + self.login() + resp = self.client.put(self.url('notes_api_note', {'note_id': note.pk}), + json.dumps(updated_dict), + content_type='application/json', + HTTP_X_REQUESTED_WITH='XMLHttpRequest') + self.assertEqual(resp.status_code, 303) + self.assertEqual(resp.content, '') + + actual = models.Note.objects.get(pk=note.pk) + actual_dict = actual.as_dict() + for field in ['text', 'tags']: + self.assertEqual(actual_dict[field], updated_dict[field]) + + def test_search_note_params(self): + self.login() + + total = 3 + notes = self.create_notes(total) + invalid_uri = ''.join([note.uri for note in notes]) + + tests = [{'limit': 0, 'offset': 0, 'expected_rows': total}, + {'limit': 0, 'offset': 2, 'expected_rows': total - 2}, + {'limit': 0, 'offset': total, 'expected_rows': 0}, + {'limit': 1, 'offset': 0, 'expected_rows': 1}, + {'limit': 2, 'offset': 0, 'expected_rows': 2}, + {'limit': total, 'offset': 2, 'expected_rows': 1}, + {'limit': total, 'offset': total, 'expected_rows': 0}, + {'limit': total + 1, 'offset': total + 1, 'expected_rows': 0}, + {'limit': total + 1, 'offset': 0, 'expected_rows': total}, + {'limit': 0, 'offset': 0, 'uri': invalid_uri, 'expected_rows': 0, 'expected_total': 0}] + + for test in tests: + params = dict([(k, str(test[k])) + for k in ('limit', 'offset', 'uri') + if k in test]) + resp = self.client.get(self.url('notes_api_search'), + params, + content_type='application/json', + HTTP_X_REQUESTED_WITH='XMLHttpRequest') + + self.assertEqual(resp.status_code, 200) + self.assertNotEqual(resp.content, '') + + content = json.loads(resp.content) + + for expected_key in ('total', 'rows'): + self.assertTrue(expected_key in content) + + if 'expected_total' in test: + self.assertEqual(content['total'], test['expected_total']) + else: + self.assertEqual(content['total'], total) + + self.assertEqual(len(content['rows']), test['expected_rows']) + + for row in content['rows']: + self.assertTrue('id' in row) + + +class NoteTest(TestCase): + def setUp(self): + self.password = 'abc' + self.student = User.objects.create_user('student', 'student@test.com', self.password) + self.course_id = 'HarvardX/CB22x/The_Ancient_Greek_Hero' + self.note = { + 'user': self.student, + 'course_id': self.course_id, + 'uri': '/', + 'text': 'foo', + 'quote': 'bar', + 'range_start': 0, + 'range_start_offset': 0, + 'range_end': 100, + 'range_end_offset': 0, + 'tags': 'a,b,c' + } + + def test_clean_valid_note(self): + reference_note = models.Note(**self.note) + body = reference_note.as_dict() + + note = models.Note(course_id=self.course_id, user=self.student) + try: + note.clean(json.dumps(body)) + self.assertEqual(note.uri, body['uri']) + self.assertEqual(note.text, body['text']) + self.assertEqual(note.quote, body['quote']) + self.assertEqual(note.range_start, body['ranges'][0]['start']) + self.assertEqual(note.range_start_offset, body['ranges'][0]['startOffset']) + self.assertEqual(note.range_end, body['ranges'][0]['end']) + self.assertEqual(note.range_end_offset, body['ranges'][0]['endOffset']) + self.assertEqual(note.tags, ','.join(body['tags'])) + except ValidationError: + self.fail('a valid note should not raise an exception') + + def test_clean_invalid_note(self): + note = models.Note(course_id=self.course_id, user=self.student) + for empty_type in (None, '', 0, []): + with self.assertRaises(ValidationError): + note.clean(None) + + with self.assertRaises(ValidationError): + note.clean(json.dumps({ + 'text': 'foo', + 'quote': 'bar', + 'ranges': [{} for i in range(10)] # too many ranges + })) + + def test_as_dict(self): + note = models.Note(course_id=self.course_id, user=self.student) + d = note.as_dict() + self.assertNotIsInstance(d, basestring) + self.assertEqual(d['user_id'], self.student.id) + self.assertTrue('course_id' not in d) diff --git a/lms/djangoapps/notes/urls.py b/lms/djangoapps/notes/urls.py new file mode 100644 index 0000000000..6abe92253a --- /dev/null +++ b/lms/djangoapps/notes/urls.py @@ -0,0 +1,10 @@ +from django.conf.urls import patterns, url + + +id_regex = r"(?P[0-9A-Fa-f]+)" +urlpatterns = patterns('notes.api', + url(r'^api$', 'api_request', {'resource': 'root'}, name='notes_api_root'), + url(r'^api/annotations$', 'api_request', {'resource': 'notes'}, name='notes_api_notes'), + url(r'^api/annotations/' + id_regex + r'$', 'api_request', {'resource': 'note'}, name='notes_api_note'), + url(r'^api/search', 'api_request', {'resource': 'search'}, name='notes_api_search') + ) diff --git a/lms/djangoapps/notes/utils.py b/lms/djangoapps/notes/utils.py new file mode 100644 index 0000000000..e6e784ce49 --- /dev/null +++ b/lms/djangoapps/notes/utils.py @@ -0,0 +1,17 @@ +from django.conf import settings + + +def notes_enabled_for_course(course): + + ''' + Returns True if the notes app is enabled for the course, False otherwise. + + In order for the app to be enabled it must be: + 1) enabled globally via MITX_FEATURES. + 2) present in the course tab configuration. + ''' + + tab_found = next((True for t in course.tabs if t['type'] == 'notes'), False) + feature_enabled = settings.MITX_FEATURES.get('ENABLE_STUDENT_NOTES') + + return feature_enabled and tab_found diff --git a/lms/djangoapps/notes/views.py b/lms/djangoapps/notes/views.py new file mode 100644 index 0000000000..654d7fb31d --- /dev/null +++ b/lms/djangoapps/notes/views.py @@ -0,0 +1,24 @@ +from django.contrib.auth.decorators import login_required +from django.http import Http404 +from mitxmako.shortcuts import render_to_response +from courseware.courses import get_course_with_access +from notes.models import Note +from notes.utils import notes_enabled_for_course +import json + + +@login_required +def notes(request, course_id): + ''' Displays the student's notes. ''' + + course = get_course_with_access(request.user, course_id, 'load') + if not notes_enabled_for_course(course): + raise Http404 + + notes = Note.objects.filter(course_id=course_id, user=request.user).order_by('-created', 'uri') + context = { + 'course': course, + 'notes': notes + } + + return render_to_response('notes.html', context) diff --git a/lms/djangoapps/open_ended_grading/open_ended_notifications.py b/lms/djangoapps/open_ended_grading/open_ended_notifications.py index 6d5f2a3eb4..1d6fa22929 100644 --- a/lms/djangoapps/open_ended_grading/open_ended_notifications.py +++ b/lms/djangoapps/open_ended_grading/open_ended_notifications.py @@ -11,6 +11,7 @@ from util.cache import cache import datetime from xmodule.x_module import ModuleSystem from mitxmako.shortcuts import render_to_string +import datetime log = logging.getLogger(__name__) @@ -104,6 +105,25 @@ def peer_grading_notifications(course, user): def combined_notifications(course, user): + """ + Show notifications to a given user for a given course. Get notifications from the cache if possible, + or from the grading controller server if not. + @param course: The course object for which we are getting notifications + @param user: The user object for which we are getting notifications + @return: A dictionary with boolean pending_grading (true if there is pending grading), img_path (for notification + image), and response (actual response from grading controller server). + """ + #Set up return values so that we can return them for error cases + pending_grading = False + img_path = "" + notifications={} + notification_dict = {'pending_grading': pending_grading, 'img_path': img_path, 'response': notifications} + + #We don't want to show anonymous users anything. + if not user.is_authenticated(): + return notification_dict + + #Define a mock modulesystem system = ModuleSystem( ajax_url=None, track_function=None, @@ -112,41 +132,44 @@ def combined_notifications(course, user): replace_urls=None, xblock_model_data= {} ) + #Initialize controller query service using our mock system controller_qs = ControllerQueryService(settings.OPEN_ENDED_GRADING_INTERFACE, system) student_id = unique_id_for_user(user) user_is_staff = has_access(user, course, 'staff') course_id = course.id notification_type = "combined" + #See if we have a stored value in the cache success, notification_dict = get_value_from_cache(student_id, course_id, notification_type) if success: return notification_dict - min_time_to_query = user.last_login + #Get the time of the last login of the user + last_login = user.last_login + + #Find the modules they have seen since they logged in last_module_seen = StudentModule.objects.filter(student=user, course_id=course_id, - modified__gt=min_time_to_query).values('modified').order_by( + modified__gt=last_login).values('modified').order_by( '-modified') last_module_seen_count = last_module_seen.count() if last_module_seen_count > 0: + #The last time they viewed an updated notification (last module seen minus how long notifications are cached) last_time_viewed = last_module_seen[0]['modified'] - datetime.timedelta(seconds=(NOTIFICATION_CACHE_TIME + 60)) else: - last_time_viewed = user.last_login + #If they have not seen any modules since they logged in, then don't refresh + return {'pending_grading': False, 'img_path': img_path, 'response': notifications} - pending_grading = False - - img_path = "" try: + #Get the notifications from the grading controller controller_response = controller_qs.check_combined_notifications(course.id, student_id, user_is_staff, last_time_viewed) - log.debug(controller_response) notifications = json.loads(controller_response) if notifications['success']: if notifications['overall_need_to_check']: pending_grading = True except: #Non catastrophic error, so no real action - notifications = {} #This is a dev_facing_error log.exception( "Problem with getting notifications from controller query service for course {0} user {1}.".format( @@ -157,6 +180,7 @@ def combined_notifications(course, user): notification_dict = {'pending_grading': pending_grading, 'img_path': img_path, 'response': notifications} + #Store the notifications in the cache set_value_in_cache(student_id, course_id, notification_type, notification_dict) return notification_dict diff --git a/lms/djangoapps/open_ended_grading/staff_grading_service.py b/lms/djangoapps/open_ended_grading/staff_grading_service.py index 57bfd7df42..2c611b4481 100644 --- a/lms/djangoapps/open_ended_grading/staff_grading_service.py +++ b/lms/djangoapps/open_ended_grading/staff_grading_service.py @@ -310,19 +310,24 @@ def save_grade(request, course_id): if request.method != 'POST': raise Http404 - - required = set(['score', 'feedback', 'submission_id', 'location', 'submission_flagged', 'rubric_scores[]']) - actual = set(request.POST.keys()) + p = request.POST + required = set(['score', 'feedback', 'submission_id', 'location', 'submission_flagged']) + skipped = 'skipped' in p + #If the instructor has skipped grading the submission, then there will not be any rubric scores. + #Only add in the rubric scores if the instructor has not skipped. + if not skipped: + required|=set(['rubric_scores[]']) + actual = set(p.keys()) missing = required - actual if len(missing) > 0: return _err_response('Missing required keys {0}'.format( ', '.join(missing))) grader_id = unique_id_for_user(request.user) - p = request.POST + location = p['location'] - skipped = 'skipped' in p + try: result_json = staff_grading_service().save_grade(course_id, diff --git a/lms/djangoapps/open_ended_grading/tests.py b/lms/djangoapps/open_ended_grading/tests.py index e554fdf0e1..13d780df12 100644 --- a/lms/djangoapps/open_ended_grading/tests.py +++ b/lms/djangoapps/open_ended_grading/tests.py @@ -5,19 +5,21 @@ django-admin.py test --settings=lms.envs.test --pythonpath=. lms/djangoapps/open """ import json -from mock import MagicMock +from mock import MagicMock, patch, Mock from django.core.urlresolvers import reverse from django.contrib.auth.models import Group +from django.http import HttpResponse +from django.conf import settings from mitxmako.shortcuts import render_to_string -from xmodule.open_ended_grading_classes import peer_grading_service +from xmodule.open_ended_grading_classes import peer_grading_service, controller_query_service from xmodule import peer_grading_module from xmodule.modulestore.django import modulestore import xmodule.modulestore.django from xmodule.x_module import ModuleSystem -from open_ended_grading import staff_grading_service +from open_ended_grading import staff_grading_service, views from courseware.access import _course_staff_group_name from courseware.tests.tests import LoginEnrollmentTestCase, TEST_DATA_XML_MODULESTORE, get_user @@ -25,10 +27,10 @@ import logging log = logging.getLogger(__name__) from django.test.utils import override_settings -from django.http import QueryDict from xmodule.tests import test_util_open_ended +from courseware.tests import factories @override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) class TestStaffGradingService(LoginEnrollmentTestCase): @@ -55,8 +57,8 @@ class TestStaffGradingService(LoginEnrollmentTestCase): def make_instructor(course): group_name = _course_staff_group_name(course.location) - g = Group.objects.create(name=group_name) - g.user_set.add(get_user(self.instructor)) + group = Group.objects.create(name=group_name) + group.user_set.add(get_user(self.instructor)) make_instructor(self.toy) @@ -76,28 +78,28 @@ class TestStaffGradingService(LoginEnrollmentTestCase): self.check_for_get_code(404, url) self.check_for_post_code(404, url) - def test_get_next(self): self.login(self.instructor, self.password) url = reverse('staff_grading_get_next', kwargs={'course_id': self.course_id}) data = {'location': self.location} - r = self.check_for_post_code(200, url, data) - d = json.loads(r.content) - self.assertTrue(d['success']) - self.assertEquals(d['submission_id'], self.mock_service.cnt) - self.assertIsNotNone(d['submission']) - self.assertIsNotNone(d['num_graded']) - self.assertIsNotNone(d['min_for_ml']) - self.assertIsNotNone(d['num_pending']) - self.assertIsNotNone(d['prompt']) - self.assertIsNotNone(d['ml_error_info']) - self.assertIsNotNone(d['max_score']) - self.assertIsNotNone(d['rubric']) + response = self.check_for_post_code(200, url, data) + content = json.loads(response.content) - def test_save_grade(self): + self.assertTrue(content['success']) + self.assertEquals(content['submission_id'], self.mock_service.cnt) + self.assertIsNotNone(content['submission']) + self.assertIsNotNone(content['num_graded']) + self.assertIsNotNone(content['min_for_ml']) + self.assertIsNotNone(content['num_pending']) + self.assertIsNotNone(content['prompt']) + self.assertIsNotNone(content['ml_error_info']) + self.assertIsNotNone(content['max_score']) + self.assertIsNotNone(content['rubric']) + + def save_grade_base(self, skip=False): self.login(self.instructor, self.password) url = reverse('staff_grading_save_grade', kwargs={'course_id': self.course_id}) @@ -108,11 +110,19 @@ class TestStaffGradingService(LoginEnrollmentTestCase): 'location': self.location, 'submission_flagged': "true", 'rubric_scores[]': ['1', '2']} + if skip: + data.update({'skipped': True}) - r = self.check_for_post_code(200, url, data) - d = json.loads(r.content) - self.assertTrue(d['success'], str(d)) - self.assertEquals(d['submission_id'], self.mock_service.cnt) + response = self.check_for_post_code(200, url, data) + content = json.loads(response.content) + self.assertTrue(content['success'], str(content)) + self.assertEquals(content['submission_id'], self.mock_service.cnt) + + def test_save_grade(self): + self.save_grade_base(skip=False) + + def test_save_grade_skip(self): + self.save_grade_base(skip=True) def test_get_problem_list(self): self.login(self.instructor, self.password) @@ -120,10 +130,11 @@ class TestStaffGradingService(LoginEnrollmentTestCase): url = reverse('staff_grading_get_problem_list', kwargs={'course_id': self.course_id}) data = {} - r = self.check_for_post_code(200, url, data) - d = json.loads(r.content) - self.assertTrue(d['success'], str(d)) - self.assertIsNotNone(d['problem_list']) + response = self.check_for_post_code(200, url, data) + content = json.loads(response.content) + + self.assertTrue(content['success'], str(content)) + self.assertIsNotNone(content['problem_list']) @override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) @@ -170,13 +181,14 @@ class TestPeerGradingService(LoginEnrollmentTestCase): def test_get_next_submission_success(self): data = {'location': self.location} - r = self.peer_module.get_next_submission(data) - d = json.loads(r) - self.assertTrue(d['success']) - self.assertIsNotNone(d['submission_id']) - self.assertIsNotNone(d['prompt']) - self.assertIsNotNone(d['submission_key']) - self.assertIsNotNone(d['max_score']) + response = self.peer_module.get_next_submission(data) + content = response + + self.assertTrue(content['success']) + self.assertIsNotNone(content['submission_id']) + self.assertIsNotNone(content['prompt']) + self.assertIsNotNone(content['submission_key']) + self.assertIsNotNone(content['max_score']) def test_get_next_submission_missing_location(self): data = {} @@ -204,9 +216,9 @@ class TestPeerGradingService(LoginEnrollmentTestCase): qdict.getlist = fake_get_item qdict.keys = data.keys - r = self.peer_module.save_grade(qdict) - d = json.loads(r) - self.assertTrue(d['success']) + response = self.peer_module.save_grade(qdict) + + self.assertTrue(response['success']) def test_save_grade_missing_keys(self): data = {} @@ -216,37 +228,35 @@ class TestPeerGradingService(LoginEnrollmentTestCase): def test_is_calibrated_success(self): data = {'location': self.location} - r = self.peer_module.is_student_calibrated(data) - d = json.loads(r) - self.assertTrue(d['success']) - self.assertTrue('calibrated' in d) + response = self.peer_module.is_student_calibrated(data) + + self.assertTrue(response['success']) + self.assertTrue('calibrated' in response) def test_is_calibrated_failure(self): data = {} - d = self.peer_module.is_student_calibrated(data) - self.assertFalse(d['success']) - self.assertFalse('calibrated' in d) + response = self.peer_module.is_student_calibrated(data) + self.assertFalse(response['success']) + self.assertFalse('calibrated' in response) def test_show_calibration_essay_success(self): data = {'location': self.location} - r = self.peer_module.show_calibration_essay(data) - d = json.loads(r) - log.debug(d) - log.debug(type(d)) - self.assertTrue(d['success']) - self.assertIsNotNone(d['submission_id']) - self.assertIsNotNone(d['prompt']) - self.assertIsNotNone(d['submission_key']) - self.assertIsNotNone(d['max_score']) + response = self.peer_module.show_calibration_essay(data) + + self.assertTrue(response['success']) + self.assertIsNotNone(response['submission_id']) + self.assertIsNotNone(response['prompt']) + self.assertIsNotNone(response['submission_key']) + self.assertIsNotNone(response['max_score']) def test_show_calibration_essay_missing_key(self): data = {} - d = self.peer_module.show_calibration_essay(data) + response = self.peer_module.show_calibration_essay(data) - self.assertFalse(d['success']) - self.assertEqual(d['error'], "Missing required keys: location") + self.assertFalse(response['success']) + self.assertEqual(response['error'], "Missing required keys: location") def test_save_calibration_essay_success(self): data = { @@ -268,13 +278,44 @@ class TestPeerGradingService(LoginEnrollmentTestCase): qdict.getlist = fake_get_item qdict.keys = data.keys - d = self.peer_module.save_calibration_essay(qdict) - self.assertTrue(d['success']) - self.assertTrue('actual_score' in d) + response = self.peer_module.save_calibration_essay(qdict) + self.assertTrue(response['success']) + self.assertTrue('actual_score' in response) def test_save_calibration_essay_missing_keys(self): data = {} - d = self.peer_module.save_calibration_essay(data) - self.assertFalse(d['success']) - self.assertTrue(d['error'].find('Missing required keys:') > -1) - self.assertFalse('actual_score' in d) + response = self.peer_module.save_calibration_essay(data) + self.assertFalse(response['success']) + self.assertTrue(response['error'].find('Missing required keys:') > -1) + self.assertFalse('actual_score' in response) + + +@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) +class TestPanel(LoginEnrollmentTestCase): + """ + Run tests on the open ended panel + """ + + def setUp(self): + # Toy courses should be loaded + self.course_name = 'edX/open_ended/2012_Fall' + self.course = modulestore().get_course(self.course_name) + self.user = factories.UserFactory() + + def test_open_ended_panel(self): + """ + Test to see if the peer grading module in the demo course is found + @return: + """ + found_module, peer_grading_module = views.find_peer_grading_module(self.course) + self.assertTrue(found_module) + + @patch('open_ended_grading.views.controller_qs', controller_query_service.MockControllerQueryService(settings.OPEN_ENDED_GRADING_INTERFACE, views.system)) + def test_problem_list(self): + """ + Ensure that the problem list from the grading controller server can be rendered properly locally + @return: + """ + request = Mock(user=self.user) + response = views.student_problem_list(request, self.course.id) + self.assertRegexpMatches(response.content, "Here are a list of open ended problems for this course.") diff --git a/lms/djangoapps/open_ended_grading/views.py b/lms/djangoapps/open_ended_grading/views.py index cb617d609d..a914e434a9 100644 --- a/lms/djangoapps/open_ended_grading/views.py +++ b/lms/djangoapps/open_ended_grading/views.py @@ -21,6 +21,7 @@ import open_ended_notifications from xmodule.modulestore.django import modulestore from xmodule.modulestore import search +from xmodule.modulestore.exceptions import ItemNotFoundError from django.http import HttpResponse, Http404, HttpResponseRedirect from mitxmako.shortcuts import render_to_string @@ -30,11 +31,12 @@ log = logging.getLogger(__name__) system = ModuleSystem( ajax_url=None, track_function=None, - get_module = None, + get_module=None, render_template=render_to_string, - replace_urls = None, - xblock_model_data= {} + replace_urls=None, + xblock_model_data={} ) + controller_qs = ControllerQueryService(settings.OPEN_ENDED_GRADING_INTERFACE, system) """ @@ -90,40 +92,61 @@ def staff_grading(request, course_id): 'staff_access': True, }) -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -def peer_grading(request, course_id): - ''' - Show a peer grading interface - ''' - - #Get the current course - course = get_course_with_access(request.user, course_id, 'load') - course_id_parts = course.id.split("/") - false_dict = [False, "False", "false", "FALSE"] - +def find_peer_grading_module(course): + """ + Given a course, finds the first peer grading module in it. + @param course: A course object. + @return: boolean found_module, string problem_url + """ #Reverse the base course url base_course_url = reverse('courses') - try: - #TODO: This will not work with multiple runs of a course. Make it work. The last key in the Location passed - #to get_items is called revision. Is this the same as run? - #Get the peer grading modules currently in the course - items = modulestore().get_items(['i4x', None, course_id_parts[1], 'peergrading', None]) - #See if any of the modules are centralized modules (ie display info from multiple problems) - items = [i for i in items if getattr(i,"use_for_single_location", True) in false_dict] - #Get the first one + found_module = False + problem_url = "" + + #Get the course id and split it + course_id_parts = course.id.split("/") + log.info("COURSE ID PARTS") + log.info(course_id_parts) + #Get the peer grading modules currently in the course. Explicitly specify the course id to avoid issues with different runs. + items = modulestore().get_items(['i4x', course_id_parts[0], course_id_parts[1], 'peergrading', None], + course_id=course.id) + #See if any of the modules are centralized modules (ie display info from multiple problems) + items = [i for i in items if not getattr(i, "use_for_single_location", True)] + #Get the first one + if len(items) > 0: item_location = items[0].location #Generate a url for the first module and redirect the user to it problem_url_parts = search.path_to_location(modulestore(), course.id, item_location) problem_url = generate_problem_url(problem_url_parts, base_course_url) + found_module = True - return HttpResponseRedirect(problem_url) - except: + return found_module, problem_url + + +@cache_control(no_cache=True, no_store=True, must_revalidate=True) +def peer_grading(request, course_id): + ''' + When a student clicks on the "peer grading" button in the open ended interface, link them to a peer grading + xmodule in the course. + ''' + + #Get the current course + course = get_course_with_access(request.user, course_id, 'load') + + found_module, problem_url = find_peer_grading_module(course) + if not found_module: #This is a student_facing_error - error_message = "Error with initializing peer grading. Centralized module does not exist. Please contact course staff." + error_message = """ + Error with initializing peer grading. + There has not been a peer grading module created in the courseware that would allow you to grade others. + Please check back later for this. + """ #This is a dev_facing_error log.exception(error_message + "Current course is: {0}".format(course_id)) return HttpResponse(error_message) + return HttpResponseRedirect(problem_url) + def generate_problem_url(problem_url_parts, base_course_url): """ @@ -145,7 +168,8 @@ def generate_problem_url(problem_url_parts, base_course_url): @cache_control(no_cache=True, no_store=True, must_revalidate=True) def student_problem_list(request, course_id): ''' - Show a student problem list + Show a student problem list to a student. Fetch the list from the grading controller server, get some metadata, + and then show it to the student. ''' course = get_course_with_access(request.user, course_id, 'load') student_id = unique_id_for_user(request.user) @@ -157,6 +181,7 @@ def student_problem_list(request, course_id): base_course_url = reverse('courses') try: + #Get list of all open ended problems that the grading server knows about problem_list_json = controller_qs.get_grading_status_list(course_id, unique_id_for_user(request.user)) problem_list_dict = json.loads(problem_list_json) success = problem_list_dict['success'] @@ -166,8 +191,22 @@ def student_problem_list(request, course_id): else: problem_list = problem_list_dict['problem_list'] + #A list of problems to remove (problems that can't be found in the course) + list_to_remove = [] for i in xrange(0, len(problem_list)): - problem_url_parts = search.path_to_location(modulestore(), course.id, problem_list[i]['location']) + try: + #Try to load each problem in the courseware to get links to them + problem_url_parts = search.path_to_location(modulestore(), course.id, problem_list[i]['location']) + except ItemNotFoundError: + #If the problem cannot be found at the location received from the grading controller server, it has been deleted by the course author. + #Continue with the rest of the location to construct the list + error_message = "Could not find module for course {0} at location {1}".format(course.id, + problem_list[i][ + 'location']) + log.error(error_message) + #Mark the problem for removal from the list + list_to_remove.append(i) + continue problem_url = generate_problem_url(problem_url_parts, base_course_url) problem_list[i].update({'actual_url': problem_url}) eta_available = problem_list[i]['eta_available'] @@ -197,6 +236,8 @@ def student_problem_list(request, course_id): log.error("Problem with results from external grading service for open ended.") success = False + #Remove problems that cannot be found in the courseware from the list + problem_list = [problem_list[i] for i in xrange(0, len(problem_list)) if i not in list_to_remove] ajax_url = _reverse_with_slash('open_ended_problems', course_id) return render_to_response('open_ended_problems/open_ended_problems.html', { @@ -300,7 +341,16 @@ def combined_notifications(request, course_id): 'description': description, 'alert_message': alert_message } - notification_list.append(notification_item) + #The open ended panel will need to link the "peer grading" button in the panel to a peer grading + #xmodule defined in the course. This checks to see if the human name of the server notification + #that we are currently processing is "peer grading". If it is, it looks for a peer grading + #module in the course. If none exists, it removes the peer grading item from the panel. + if human_name == "Peer Grading": + found_module, problem_url = find_peer_grading_module(course) + if found_module: + notification_list.append(notification_item) + else: + notification_list.append(notification_item) ajax_url = _reverse_with_slash('open_ended_notifications', course_id) combined_dict = { @@ -311,9 +361,7 @@ def combined_notifications(request, course_id): 'ajax_url': ajax_url, } - return render_to_response('open_ended_problems/combined_notifications.html', - combined_dict - ) + return render_to_response('open_ended_problems/combined_notifications.html', combined_dict) @cache_control(no_cache=True, no_store=True, must_revalidate=True) diff --git a/lms/djangoapps/simplewiki/urls.py b/lms/djangoapps/simplewiki/urls.py index cf243e0bd3..629b753654 100644 --- a/lms/djangoapps/simplewiki/urls.py +++ b/lms/djangoapps/simplewiki/urls.py @@ -4,16 +4,16 @@ namespace_regex = r"[a-zA-Z\d._-]+" article_slug = r'/(?P' + namespace_regex + r'/[a-zA-Z\d_-]*)' namespace = r'/(?P' + namespace_regex + r')' -urlpatterns = patterns('', - url(r'^$', 'simplewiki.views.root_redirect', name='wiki_root'), - url(r'^view' + article_slug, 'simplewiki.views.view', name='wiki_view'), - url(r'^view_revision/(?P[0-9]+)' + article_slug, 'simplewiki.views.view_revision', name='wiki_view_revision'), - url(r'^edit' + article_slug, 'simplewiki.views.edit', name='wiki_edit'), - url(r'^create' + article_slug, 'simplewiki.views.create', name='wiki_create'), - url(r'^history' + article_slug + r'(?:/(?P[0-9]+))?$', 'simplewiki.views.history', name='wiki_history'), - url(r'^search_related' + article_slug, 'simplewiki.views.search_add_related', name='search_related'), - url(r'^random/?$', 'simplewiki.views.random_article', name='wiki_random'), - url(r'^revision_feed' + namespace + r'/(?P[0-9]+)?$', 'simplewiki.views.revision_feed', name='wiki_revision_feed'), - url(r'^search' + namespace + r'?$', 'simplewiki.views.search_articles', name='wiki_search_articles'), - url(r'^list' + namespace + r'?$', 'simplewiki.views.search_articles', name='wiki_list_articles'), # Just an alias for the search, but you usually don't submit a search term +urlpatterns = patterns('', # nopep8 + url(r'^$', 'simplewiki.views.root_redirect', name='wiki_root'), + url(r'^view' + article_slug, 'simplewiki.views.view', name='wiki_view'), + url(r'^view_revision/(?P[0-9]+)' + article_slug, 'simplewiki.views.view_revision', name='wiki_view_revision'), + url(r'^edit' + article_slug, 'simplewiki.views.edit', name='wiki_edit'), + url(r'^create' + article_slug, 'simplewiki.views.create', name='wiki_create'), + url(r'^history' + article_slug + r'(?:/(?P[0-9]+))?$', 'simplewiki.views.history', name='wiki_history'), + url(r'^search_related' + article_slug, 'simplewiki.views.search_add_related', name='search_related'), + url(r'^random/?$', 'simplewiki.views.random_article', name='wiki_random'), + url(r'^revision_feed' + namespace + r'/(?P[0-9]+)?$', 'simplewiki.views.revision_feed', name='wiki_revision_feed'), + url(r'^search' + namespace + r'?$', 'simplewiki.views.search_articles', name='wiki_search_articles'), + url(r'^list' + namespace + r'?$', 'simplewiki.views.search_articles', name='wiki_list_articles'), # Just an alias for the search, but you usually don't submit a search term ) diff --git a/lms/djangoapps/static_template_view/tests.py b/lms/djangoapps/static_template_view/tests.py index 501deb776c..9cd5502d5d 100644 --- a/lms/djangoapps/static_template_view/tests.py +++ b/lms/djangoapps/static_template_view/tests.py @@ -1,16 +1,61 @@ -""" -This file demonstrates writing tests using the unittest module. These will pass -when you run "manage.py test". - -Replace this with more appropriate tests for your application. -""" - from django.test import TestCase +from django.test.client import Client class SimpleTest(TestCase): - def test_basic_addition(self): + + def setUp(self): + self.client = Client() + + def test_render(self): """ - Tests that 1 + 1 always equals 2. + Render a normal page, like jobs """ - self.assertEqual(1 + 1, 2) + response = self.client.get("/jobs") + self.assertEquals(response.status_code, 200) + + + def test_render_press_release(self): + """ + Render press releases from generic URL match + """ + # since I had to remap files, pedantically test all press releases + # published to date. Decent positive test while we're at it. + all_releases = ["/press/mit-and-harvard-announce-edx", + "/press/uc-berkeley-joins-edx", + "/press/edX-announces-proctored-exam-testing", + "/press/elsevier-collaborates-with-edx", + "/press/ut-joins-edx", + "/press/cengage-to-provide-book-content", + "/press/gates-foundation-announcement", + "/press/wellesley-college-joins-edx", + "/press/georgetown-joins-edx", + "/press/spring-courses", + "/press/lewin-course-announcement", + "/press/bostonx-announcement", + "/press/eric-lander-secret-of-life", + "/press/edx-expands-internationally", + "/press/xblock_announcement", + "/press/stanford-to-work-with-edx", + ] + + for rel in all_releases: + response = self.client.get(rel) + self.assertNotContains(response, "PAGE NOT FOUND", status_code=200) + + # should work with caps + response = self.client.get("/press/STANFORD-to-work-with-edx") + self.assertContains(response, "Stanford", status_code=200) + + # negative test + response = self.client.get("/press/this-shouldnt-work") + self.assertEqual(response.status_code, 404) + + # can someone do something fishy? no. + response = self.client.get("/press/../homework.html") + self.assertEqual(response.status_code, 404) + + # "." in is ascii 2E + response = self.client.get("/press/%2E%2E/homework.html") + self.assertEqual(response.status_code, 404) + diff --git a/lms/djangoapps/static_template_view/views.py b/lms/djangoapps/static_template_view/views.py index 022f12b148..56a7f32780 100644 --- a/lms/djangoapps/static_template_view/views.py +++ b/lms/djangoapps/static_template_view/views.py @@ -4,9 +4,10 @@ # security reasons. from mitxmako.shortcuts import render_to_response, render_to_string +from mako.exceptions import TopLevelLookupException from django.shortcuts import redirect from django.conf import settings -from django.http import HttpResponseNotFound, HttpResponseServerError +from django.http import HttpResponseNotFound, HttpResponseServerError, Http404 from django_future.csrf import ensure_csrf_cookie from util.cache import cache_if_anonymous @@ -40,6 +41,25 @@ def render(request, template): return render_to_response('static_templates/' + template, {}) +@ensure_csrf_cookie +@cache_if_anonymous +def render_press_release(request, slug): + """ + Render a press release given a slug. Similar to the "render" function above, + but takes a slug and does a basic conversion to convert it to a template file. + a) all lower case, + b) convert dashes to underscores, and + c) appending ".html" + """ + template = slug.lower().replace('-', '_') + ".html" + try: + resp = render_to_response('static_templates/press_releases/' + template, {}) + except TopLevelLookupException: + raise Http404 + else: + return resp + + def render_404(request): return HttpResponseNotFound(render_to_string('static_templates/404.html', {})) diff --git a/lms/djangoapps/staticbook/views.py b/lms/djangoapps/staticbook/views.py index aa1ba68d72..6d3dcbd5ca 100644 --- a/lms/djangoapps/staticbook/views.py +++ b/lms/djangoapps/staticbook/views.py @@ -1,11 +1,11 @@ -from lxml import etree - from django.contrib.auth.decorators import login_required from django.http import Http404 +from django.core.urlresolvers import reverse from mitxmako.shortcuts import render_to_response from courseware.access import has_access from courseware.courses import get_course_with_access +from notes.utils import notes_enabled_for_course from static_replace import replace_static_urls @@ -25,7 +25,8 @@ def index(request, course_id, book_index, page=None): return render_to_response('staticbook.html', {'book_index': book_index, 'page': int(page), - 'course': course, 'book_url': textbook.book_url, + 'course': course, + 'book_url': textbook.book_url, 'table_of_contents': table_of_contents, 'start_page': textbook.start_page, 'end_page': textbook.end_page, @@ -38,6 +39,20 @@ def index_shifted(request, course_id, page): @login_required def pdf_index(request, course_id, book_index, chapter=None, page=None): + """ + Display a PDF textbook. + + course_id: course for which to display text. The course should have + "pdf_textbooks" property defined. + + book index: zero-based index of which PDF textbook to display. + + chapter: (optional) one-based index into the chapter array of textbook PDFs to display. + Defaults to first chapter. Specifying this assumes that there are separate PDFs for + each chapter in a textbook. + + page: (optional) one-based page number to display within the PDF. Defaults to first page. + """ course = get_course_with_access(request.user, course_id, 'load') staff_access = has_access(request.user, course, 'staff') @@ -63,7 +78,6 @@ def pdf_index(request, course_id, book_index, chapter=None, page=None): for entry in textbook['chapters']: entry['url'] = remap_static_url(entry['url'], course) - return render_to_response('static_pdfbook.html', {'book_index': book_index, 'course': course, @@ -72,10 +86,24 @@ def pdf_index(request, course_id, book_index, chapter=None, page=None): 'page': page, 'staff_access': staff_access}) + @login_required -def html_index(request, course_id, book_index, chapter=None, anchor_id=None): +def html_index(request, course_id, book_index, chapter=None): + """ + Display an HTML textbook. + + course_id: course for which to display text. The course should have + "html_textbooks" property defined. + + book index: zero-based index of which HTML textbook to display. + + chapter: (optional) one-based index into the chapter array of textbook HTML files to display. + Defaults to first chapter. Specifying this assumes that there are separate HTML files for + each chapter in a textbook. + """ course = get_course_with_access(request.user, course_id, 'load') staff_access = has_access(request.user, course, 'staff') + notes_enabled = notes_enabled_for_course(course) book_index = int(book_index) if book_index < 0 or book_index >= len(course.html_textbooks): @@ -99,11 +127,10 @@ def html_index(request, course_id, book_index, chapter=None, anchor_id=None): for entry in textbook['chapters']: entry['url'] = remap_static_url(entry['url'], course) - return render_to_response('static_htmlbook.html', {'book_index': book_index, 'course': course, 'textbook': textbook, 'chapter': chapter, - 'anchor_id': anchor_id, - 'staff_access': staff_access}) + 'staff_access': staff_access, + 'notes_enabled': notes_enabled}) diff --git a/lms/envs/acceptance.py b/lms/envs/acceptance.py index 5f416cd189..3b87bb4326 100644 --- a/lms/envs/acceptance.py +++ b/lms/envs/acceptance.py @@ -2,19 +2,28 @@ This config file extends the test environment configuration so that we can run the lettuce acceptance tests. """ + +# We intentionally define lots of variables that aren't used, and +# want to import all variables from base settings files +# pylint: disable=W0401, W0614 + from .test import * # You need to start the server in debug mode, # otherwise the browser will not render the pages correctly DEBUG = True +# Disable warnings for acceptance tests, to make the logs readable +import logging +logging.disable(logging.ERROR) + # Use the mongo store for acceptance tests modulestore_options = { 'default_class': 'xmodule.raw_module.RawDescriptor', 'host': 'localhost', 'db': 'test_xmodule', - 'collection': 'modulestore', - 'fs_root': GITHUB_REPO_ROOT, + 'collection': 'acceptance_modulestore', + 'fs_root': TEST_ROOT / "data", 'render_template': 'mitxmako.shortcuts.render_to_string', } @@ -33,7 +42,7 @@ CONTENTSTORE = { 'ENGINE': 'xmodule.contentstore.mongo.MongoContentStore', 'OPTIONS': { 'host': 'localhost', - 'db': 'test_xcontent', + 'db': 'test_xmodule', } } @@ -43,8 +52,8 @@ CONTENTSTORE = { DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': ENV_ROOT / "db" / "test_mitx.db", - 'TEST_NAME': ENV_ROOT / "db" / "test_mitx.db", + 'NAME': TEST_ROOT / "db" / "test_mitx.db", + 'TEST_NAME': TEST_ROOT / "db" / "test_mitx.db", } } @@ -67,3 +76,4 @@ MITX_FEATURES['STUB_VIDEO_FOR_TESTING'] = True # Include the lettuce app for acceptance testing, including the 'harvest' django-admin command INSTALLED_APPS += ('lettuce.django',) LETTUCE_APPS = ('courseware',) +LETTUCE_BROWSER = 'chrome' diff --git a/lms/envs/aws.py b/lms/envs/aws.py index aa30315eca..a3d5cb653f 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -6,6 +6,11 @@ Common traits: * Use memcached, and cache-backed sessions * Use a MySQL 5.1 database """ + +# We intentionally define lots of variables that aren't used, and +# want to import all variables from base settings files +# pylint: disable=W0401, W0614 + import json from .common import * @@ -26,7 +31,8 @@ if SERVICE_VARIANT: CONFIG_PREFIX = SERVICE_VARIANT + "." -################### ALWAYS THE SAME ################################ +################################ ALWAYS THE SAME ############################## + DEBUG = False TEMPLATE_DEBUG = False @@ -45,7 +51,49 @@ MITX_FEATURES['ENABLE_DISCUSSION_SERVICE'] = True # for other warnings. SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') -################# NON-SECURE ENV CONFIG ############################## +###################################### CELERY ################################ + +# Don't use a connection pool, since connections are dropped by ELB. +BROKER_POOL_LIMIT = 0 +BROKER_CONNECTION_TIMEOUT = 1 + +# For the Result Store, use the django cache named 'celery' +CELERY_RESULT_BACKEND = 'cache' +CELERY_CACHE_BACKEND = 'celery' + +# When the broker is behind an ELB, use a heartbeat to refresh the +# connection and to detect if it has been dropped. +BROKER_HEARTBEAT = 10.0 +BROKER_HEARTBEAT_CHECKRATE = 2 + +# Each worker should only fetch one message at a time +CELERYD_PREFETCH_MULTIPLIER = 1 + +# Skip djcelery migrations, since we don't use the database as the broker +SOUTH_MIGRATION_MODULES = { + 'djcelery': 'ignore', +} + +# Rename the exchange and queues for each variant + +QUEUE_VARIANT = CONFIG_PREFIX.lower() + +CELERY_DEFAULT_EXCHANGE = 'edx.{0}core'.format(QUEUE_VARIANT) + +HIGH_PRIORITY_QUEUE = 'edx.{0}core.high'.format(QUEUE_VARIANT) +DEFAULT_PRIORITY_QUEUE = 'edx.{0}core.default'.format(QUEUE_VARIANT) +LOW_PRIORITY_QUEUE = 'edx.{0}core.low'.format(QUEUE_VARIANT) + +CELERY_DEFAULT_QUEUE = DEFAULT_PRIORITY_QUEUE +CELERY_DEFAULT_ROUTING_KEY = DEFAULT_PRIORITY_QUEUE + +CELERY_QUEUES = { + HIGH_PRIORITY_QUEUE: {}, + LOW_PRIORITY_QUEUE: {}, + DEFAULT_PRIORITY_QUEUE: {} +} + +########################## NON-SECURE ENV CONFIG ############################## # Things like server locations, ports, etc. with open(ENV_ROOT / CONFIG_PREFIX + "env.json") as env_file: @@ -60,6 +108,20 @@ LOG_DIR = ENV_TOKENS['LOG_DIR'] CACHES = ENV_TOKENS['CACHES'] +#Email overrides +DEFAULT_FROM_EMAIL = ENV_TOKENS.get('DEFAULT_FROM_EMAIL', DEFAULT_FROM_EMAIL) +DEFAULT_FEEDBACK_EMAIL = ENV_TOKENS.get('DEFAULT_FEEDBACK_EMAIL', DEFAULT_FEEDBACK_EMAIL) +ADMINS = ENV_TOKENS.get('ADMINS', ADMINS) +SERVER_EMAIL = ENV_TOKENS.get('SERVER_EMAIL', SERVER_EMAIL) + +#Theme overrides +THEME_NAME = ENV_TOKENS.get('THEME_NAME', None) +if not THEME_NAME is None: + enable_theme(THEME_NAME) + +#Timezone overrides +TIME_ZONE = ENV_TOKENS.get('TIME_ZONE', TIME_ZONE) + for feature, value in ENV_TOKENS.get('MITX_FEATURES', {}).items(): MITX_FEATURES[feature] = value @@ -80,9 +142,23 @@ META_UNIVERSITIES = ENV_TOKENS.get('META_UNIVERSITIES', {}) COMMENTS_SERVICE_URL = ENV_TOKENS.get("COMMENTS_SERVICE_URL", '') COMMENTS_SERVICE_KEY = ENV_TOKENS.get("COMMENTS_SERVICE_KEY", '') CERT_QUEUE = ENV_TOKENS.get("CERT_QUEUE", 'test-pull') +ZENDESK_URL = ENV_TOKENS.get("ZENDESK_URL") +FEEDBACK_SUBMISSION_EMAIL = ENV_TOKENS.get("FEEDBACK_SUBMISSION_EMAIL") +MKTG_URLS = ENV_TOKENS.get('MKTG_URLS', MKTG_URLS) + +for name, value in ENV_TOKENS.get("CODE_JAIL", {}).items(): + oldvalue = CODE_JAIL.get(name) + if isinstance(oldvalue, dict): + for subname, subvalue in value.items(): + oldvalue[subname] = subvalue + else: + CODE_JAIL[name] = value + +COURSES_WITH_UNSAFE_CODE = ENV_TOKENS.get("COURSES_WITH_UNSAFE_CODE", []) ############################## SECURE AUTH ITEMS ############### # Secret things: passwords, access keys, etc. + with open(ENV_ROOT / CONFIG_PREFIX + "auth.json") as auth_file: AUTH_TOKENS = json.load(auth_file) @@ -101,7 +177,8 @@ XQUEUE_INTERFACE = AUTH_TOKENS['XQUEUE_INTERFACE'] MODULESTORE = AUTH_TOKENS.get('MODULESTORE', MODULESTORE) CONTENTSTORE = AUTH_TOKENS.get('CONTENTSTORE', CONTENTSTORE) -OPEN_ENDED_GRADING_INTERFACE = AUTH_TOKENS.get('OPEN_ENDED_GRADING_INTERFACE', OPEN_ENDED_GRADING_INTERFACE) +OPEN_ENDED_GRADING_INTERFACE = AUTH_TOKENS.get('OPEN_ENDED_GRADING_INTERFACE', + OPEN_ENDED_GRADING_INTERFACE) PEARSON_TEST_USER = "pearsontest" PEARSON_TEST_PASSWORD = AUTH_TOKENS.get("PEARSON_TEST_PASSWORD") @@ -115,3 +192,18 @@ DATADOG_API = AUTH_TOKENS.get("DATADOG_API") # Analytics dashboard server ANALYTICS_SERVER_URL = ENV_TOKENS.get("ANALYTICS_SERVER_URL") ANALYTICS_API_KEY = AUTH_TOKENS.get("ANALYTICS_API_KEY", "") + +# Zendesk +ZENDESK_USER = AUTH_TOKENS.get("ZENDESK_USER") +ZENDESK_API_KEY = AUTH_TOKENS.get("ZENDESK_API_KEY") + +# Celery Broker +CELERY_BROKER_TRANSPORT = ENV_TOKENS.get("CELERY_BROKER_TRANSPORT", "") +CELERY_BROKER_HOSTNAME = ENV_TOKENS.get("CELERY_BROKER_HOSTNAME", "") +CELERY_BROKER_USER = AUTH_TOKENS.get("CELERY_BROKER_USER", "") +CELERY_BROKER_PASSWORD = AUTH_TOKENS.get("CELERY_BROKER_PASSWORD", "") + +BROKER_URL = "{0}://{1}:{2}@{3}".format(CELERY_BROKER_TRANSPORT, + CELERY_BROKER_USER, + CELERY_BROKER_PASSWORD, + CELERY_BROKER_HOSTNAME) diff --git a/lms/envs/cms/acceptance.py b/lms/envs/cms/acceptance.py index e5ee2937f4..0b638dca8a 100644 --- a/lms/envs/cms/acceptance.py +++ b/lms/envs/cms/acceptance.py @@ -3,6 +3,11 @@ This config file is a copy of dev environment without the Debug Toolbar. I it suitable to run against acceptance tests. """ + +# We intentionally define lots of variables that aren't used, and +# want to import all variables from base settings files +# pylint: disable=W0401, W0614 + from .dev import * # REMOVE DEBUG TOOLBAR diff --git a/lms/envs/cms/aws.py b/lms/envs/cms/aws.py index a0e2f25d83..baeaebca1c 100644 --- a/lms/envs/cms/aws.py +++ b/lms/envs/cms/aws.py @@ -2,6 +2,10 @@ Settings for the LMS that runs alongside the CMS on AWS """ +# We intentionally define lots of variables that aren't used, and +# want to import all variables from base settings files +# pylint: disable=W0401, W0614 + from ..aws import * with open(ENV_ROOT / "cms.auth.json") as auth_file: diff --git a/lms/envs/cms/dev.py b/lms/envs/cms/dev.py index 9333b7883c..e55c6d61b5 100644 --- a/lms/envs/cms/dev.py +++ b/lms/envs/cms/dev.py @@ -2,6 +2,10 @@ Settings for the LMS that runs alongside the CMS on AWS """ +# We intentionally define lots of variables that aren't used, and +# want to import all variables from base settings files +# pylint: disable=W0401, W0614 + from ..dev import * MITX_FEATURES['AUTH_USE_MIT_CERTIFICATES'] = False diff --git a/lms/envs/cms/preview_dev.py b/lms/envs/cms/preview_dev.py index 463af34624..1cfaec6159 100644 --- a/lms/envs/cms/preview_dev.py +++ b/lms/envs/cms/preview_dev.py @@ -2,6 +2,10 @@ Settings for the LMS that runs alongside the CMS on AWS """ +# We intentionally define lots of variables that aren't used, and +# want to import all variables from base settings files +# pylint: disable=W0401, W0614 + from .dev import * MODULESTORE = { diff --git a/lms/envs/common.py b/lms/envs/common.py index 8654b5ebf5..f75dcf8804 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -18,9 +18,13 @@ Longer TODO: 3. We need to handle configuration for multiple courses. This could be as multiple sites, but we do need a way to map their data assets. """ + +# We intentionally define lots of variables that aren't used, and +# want to import all variables from base settings files +# pylint: disable=W0401, W0614 + import sys import os -from xmodule.static_content import write_module_styles, write_module_js from path import path @@ -37,6 +41,7 @@ DISCUSSION_SETTINGS = { 'MAX_COMMENT_DEPTH': 2, } + # Features MITX_FEATURES = { 'SAMPLE': False, @@ -67,10 +72,13 @@ MITX_FEATURES = { 'ENABLE_PSYCHOMETRICS': False, # real-time psychometrics (eg item response theory analysis in instructor dashboard) + 'ENABLE_DJANGO_ADMIN_SITE': False, # set true to enable django's admin site, even on prod (e.g. for course ops) 'ENABLE_SQL_TRACKING_LOGS': False, 'ENABLE_LMS_MIGRATION': False, 'ENABLE_MANUAL_GIT_RELOAD': False, + 'ENABLE_MASQUERADE': True, # allow course staff to change to student view of courseware + 'DISABLE_LOGIN_BUTTON': False, # used in systems where login is automatic, eg MIT SSL 'STUB_VIDEO_FOR_TESTING': False, # do not display video when running automated acceptance tests @@ -89,7 +97,26 @@ MITX_FEATURES = { # Give a UI to show a student's submission history in a problem by the # Staff Debug tool. - 'ENABLE_STUDENT_HISTORY_VIEW': True + 'ENABLE_STUDENT_HISTORY_VIEW': True, + + # Enables the student notes API and UI. + 'ENABLE_STUDENT_NOTES': True, + + # Provide a UI to allow users to submit feedback from the LMS + 'ENABLE_FEEDBACK_SUBMISSION': False, + + # Turn on a page that lets staff enter Python code to be run in the + # sandbox, for testing whether it's enabled properly. + 'ENABLE_DEBUG_RUN_PYTHON': False, + + # Enable URL that shows information about the status of variuous services + 'ENABLE_SERVICE_STATUS': False, + + # Toggle to indicate use of a custom theme + 'USE_CUSTOM_THEME': False, + + # Do autoplay videos for students + 'AUTOPLAY_VIDEOS': True } # Used for A/B testing @@ -119,9 +146,7 @@ sys.path.append(COMMON_ROOT / 'lib') # For Node.js -system_node_path = os.environ.get("NODE_PATH", None) -if system_node_path is None: - system_node_path = "/usr/local/lib/node_modules" +system_node_path = os.environ.get("NODE_PATH", REPO_ROOT / 'node_modules') node_paths = [COMMON_ROOT / "static/js/vendor", COMMON_ROOT / "static/coffee/src", @@ -149,12 +174,12 @@ MAKO_TEMPLATES['main'] = [PROJECT_ROOT / 'templates', # This is where Django Template lookup is defined. There are a few of these # still left lying around. -TEMPLATE_DIRS = ( +TEMPLATE_DIRS = [ PROJECT_ROOT / "templates", COMMON_ROOT / 'templates', COMMON_ROOT / 'lib' / 'capa' / 'capa' / 'templates', COMMON_ROOT / 'djangoapps' / 'pipeline_mako' / 'templates', -) +] TEMPLATE_CONTEXT_PROCESSORS = ( 'django.core.context_processors.request', @@ -170,6 +195,9 @@ TEMPLATE_CONTEXT_PROCESSORS = ( 'django.contrib.messages.context_processors.messages', 'sekizai.context_processors.sekizai', 'course_wiki.course_nav.context_processor', + + # Hack to get required link URLs to password reset templates + 'mitxmako.shortcuts.marketing_link_context_processor', ) STUDENT_FILEUPLOAD_MAX_SIZE = 4 * 1000 * 1000 # 4 MB @@ -241,6 +269,31 @@ MODULESTORE = { } CONTENTSTORE = None +#################### Python sandbox ############################################ + +CODE_JAIL = { + # Path to a sandboxed Python executable. None means don't bother. + 'python_bin': None, + # User to run as in the sandbox. + 'user': 'sandbox', + + # Configurable limits. + 'limits': { + # How many CPU seconds can jailed code use? + 'CPU': 1, + }, +} + +# Some courses are allowed to run unsafe code. This is a list of regexes, one +# of them must match the course id for that course to run unsafe code. +# +# For example: +# +# COURSES_WITH_UNSAFE_CODE = [ +# r"Harvard/XY123.1/.*" +# ] +COURSES_WITH_UNSAFE_CODE = [] + ############################ SIGNAL HANDLERS ################################ # This is imported to register the exception signal handling that logs exceptions import monitoring.exceptions # noqa @@ -261,6 +314,7 @@ IGNORABLE_404_ENDS = ('favicon.ico') EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' DEFAULT_FROM_EMAIL = 'registration@edx.org' DEFAULT_FEEDBACK_EMAIL = 'feedback@edx.org' +SERVER_EMAIL = 'devops@edx.org' ADMINS = ( ('edX Admins', 'admin@edx.org'), ) @@ -322,6 +376,14 @@ WIKI_LINK_DEFAULT_LEVEL = 2 PEARSONVUE_SIGNINPAGE_URL = "https://www1.pearsonvue.com/testtaker/signin/SignInPage/EDX" # TESTCENTER_ACCOMMODATION_REQUEST_EMAIL = "exam-help@edx.org" +##### Feedback submission mechanism ##### +FEEDBACK_SUBMISSION_EMAIL = None + +##### Zendesk ##### +ZENDESK_URL = None +ZENDESK_USER = None +ZENDESK_API_KEY = None + ################################# open ended grading config ##################### #By setting up the default settings with an incorrect user name and password, @@ -384,27 +446,21 @@ MIDDLEWARE_CLASSES = ( # 'debug_toolbar.middleware.DebugToolbarMiddleware', 'django_comment_client.utils.ViewNameMiddleware', + 'codejail.django_integration.ConfigureCodeJailMiddleware', ) ############################### Pipeline ####################################### STATICFILES_STORAGE = 'pipeline.storage.PipelineCachedStorage' -from xmodule.hidden_module import HiddenDescriptor -from rooted_paths import rooted_glob, remove_root - -write_module_styles(PROJECT_ROOT / 'static/sass/module', [HiddenDescriptor]) -module_js = remove_root( - PROJECT_ROOT / 'static', - write_module_js(PROJECT_ROOT / 'static/coffee/module', [HiddenDescriptor]) -) +from rooted_paths import rooted_glob courseware_js = ( [ - 'coffee/src/' + pth + '.coffee' + 'coffee/src/' + pth + '.js' for pth in ['courseware', 'histogram', 'navigation', 'time'] ] + - sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/modules/**/*.coffee')) + sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/modules/**/*.js')) ) # 'js/vendor/RequireJS.js' - Require JS wrapper. @@ -418,15 +474,19 @@ main_vendor_js = [ 'js/vendor/jquery.qtip.min.js', 'js/vendor/swfobject/swfobject.js', 'js/vendor/jquery.ba-bbq.min.js', + 'js/vendor/annotator.min.js', + 'js/vendor/annotator.store.min.js', + 'js/vendor/annotator.tags.min.js' ] -discussion_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/discussion/**/*.coffee')) -staff_grading_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/staff_grading/**/*.coffee')) -open_ended_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/open_ended/**/*.coffee')) +discussion_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/discussion/**/*.js')) +staff_grading_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/staff_grading/**/*.js')) +open_ended_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/open_ended/**/*.js')) +notes_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/notes/**/*.coffee')) PIPELINE_CSS = { 'application': { - 'source_filenames': ['sass/application.scss'], + 'source_filenames': ['sass/application.css'], 'output_filename': 'css/lms-application.css', }, 'course': { @@ -435,25 +495,29 @@ PIPELINE_CSS = { 'css/vendor/jquery.treeview.css', 'css/vendor/ui-lightness/jquery-ui-1.8.22.custom.css', 'css/vendor/jquery.qtip.min.css', - 'sass/course.scss' + 'css/vendor/annotator.min.css', + 'sass/course.css', + 'xmodule/modules.css', ], 'output_filename': 'css/lms-course.css', }, 'ie-fixes': { - 'source_filenames': ['sass/ie.scss'], + 'source_filenames': ['sass/ie.css'], 'output_filename': 'css/lms-ie.css', }, } -PIPELINE_ALWAYS_RECOMPILE = ['sass/application.scss', 'sass/ie.scss', 'sass/course.scss'] + +# test_order: Determines the position of this chunk of javascript on +# the jasmine test page PIPELINE_JS = { 'application': { # Application will contain all paths not in courseware_only_js 'source_filenames': sorted( - set(rooted_glob(COMMON_ROOT / 'static', 'coffee/src/**/*.coffee') + - rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/**/*.coffee')) - - set(courseware_js + discussion_js + staff_grading_js + open_ended_js) + set(rooted_glob(COMMON_ROOT / 'static', 'coffee/src/**/*.js') + + rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/**/*.js')) - + set(courseware_js + discussion_js + staff_grading_js + open_ended_js + notes_js) ) + [ 'js/form.ext.js', 'js/my_courses_dropdown.js', @@ -461,32 +525,45 @@ PIPELINE_JS = { 'js/sticky_filter.js', 'js/query-params.js', ], - 'output_filename': 'js/lms-application.js' + 'output_filename': 'js/lms-application.js', + + 'test_order': 1, }, 'courseware': { 'source_filenames': courseware_js, - 'output_filename': 'js/lms-courseware.js' + 'output_filename': 'js/lms-courseware.js', + 'test_order': 2, }, 'main_vendor': { 'source_filenames': main_vendor_js, 'output_filename': 'js/lms-main_vendor.js', + 'test_order': 0, }, 'module-js': { - 'source_filenames': module_js, + 'source_filenames': rooted_glob(COMMON_ROOT / 'static', 'xmodule/modules/js/*.js'), 'output_filename': 'js/lms-modules.js', + 'test_order': 3, }, 'discussion': { 'source_filenames': discussion_js, - 'output_filename': 'js/discussion.js' + 'output_filename': 'js/discussion.js', + 'test_order': 4, }, 'staff_grading': { 'source_filenames': staff_grading_js, - 'output_filename': 'js/staff_grading.js' + 'output_filename': 'js/staff_grading.js', + 'test_order': 5, }, 'open_ended': { 'source_filenames': open_ended_js, - 'output_filename': 'js/open_ended.js' - } + 'output_filename': 'js/open_ended.js', + 'test_order': 6, + }, + 'notes': { + 'source_filenames': notes_js, + 'output_filename': 'js/notes.js', + 'test_order': 7 + }, } PIPELINE_DISABLE_WRAPPER = True @@ -510,12 +587,6 @@ if os.path.isdir(DATA_DIR): os.system("rm %s" % (js_dir / new_filename)) os.system("coffee -c %s" % (js_dir / filename)) -PIPELINE_COMPILERS = [ - 'pipeline.compilers.sass.SASSCompiler', - 'pipeline.compilers.coffee.CoffeeScriptCompiler', -] - -PIPELINE_SASS_ARGUMENTS = '-t compressed -r {proj_dir}/static/sass/bourbon/lib/bourbon.rb'.format(proj_dir=PROJECT_ROOT) PIPELINE_CSS_COMPRESSOR = None PIPELINE_JS_COMPRESSOR = None @@ -526,13 +597,56 @@ STATICFILES_IGNORE_PATTERNS = ( ) PIPELINE_YUI_BINARY = 'yui-compressor' -PIPELINE_SASS_BINARY = 'sass' -PIPELINE_COFFEE_SCRIPT_BINARY = 'coffee' # Setting that will only affect the MITx version of django-pipeline until our changes are merged upstream PIPELINE_COMPILE_INPLACE = True -################################### APPS ####################################### +################################# CELERY ###################################### + +# Message configuration + +CELERY_TASK_SERIALIZER = 'json' +CELERY_RESULT_SERIALIZER = 'json' + +CELERY_MESSAGE_COMPRESSION = 'gzip' + +# Results configuration + +CELERY_IGNORE_RESULT = False +CELERY_STORE_ERRORS_EVEN_IF_IGNORED = True + +# Events configuration + +CELERY_TRACK_STARTED = True + +CELERY_SEND_EVENTS = True +CELERY_SEND_TASK_SENT_EVENT = True + +# Exchange configuration + +CELERY_DEFAULT_EXCHANGE = 'edx.core' +CELERY_DEFAULT_EXCHANGE_TYPE = 'direct' + +# Queues configuration + +HIGH_PRIORITY_QUEUE = 'edx.core.high' +DEFAULT_PRIORITY_QUEUE = 'edx.core.default' +LOW_PRIORITY_QUEUE = 'edx.core.low' + +CELERY_QUEUE_HA_POLICY = 'all' + +CELERY_CREATE_MISSING_QUEUES = True + +CELERY_DEFAULT_QUEUE = DEFAULT_PRIORITY_QUEUE +CELERY_DEFAULT_ROUTING_KEY = DEFAULT_PRIORITY_QUEUE + +CELERY_QUEUES = { + HIGH_PRIORITY_QUEUE: {}, + LOW_PRIORITY_QUEUE: {}, + DEFAULT_PRIORITY_QUEUE: {} +} + +################################### APPS ###################################### INSTALLED_APPS = ( # Standard ones that are always installed... 'django.contrib.auth', @@ -541,8 +655,12 @@ INSTALLED_APPS = ( 'django.contrib.messages', 'django.contrib.sessions', 'django.contrib.sites', + 'djcelery', 'south', + # Monitor the status of services + 'service_status', + # For asset pipelining 'pipeline', 'staticfiles', @@ -581,7 +699,52 @@ INSTALLED_APPS = ( # For testing 'django.contrib.admin', # only used in DEBUG mode + 'debug', # Discussion forums 'django_comment_client', + 'django_comment_common', + 'notes', ) + +######################### MARKETING SITE ############################### +EDXMKTG_COOKIE_NAME = 'edxloggedin' +MKTG_URLS = {} +MKTG_URL_LINK_MAP = { + 'ABOUT': 'about_edx', + 'CONTACT': 'contact', + 'FAQ': 'help_edx', + 'COURSES': 'courses', + 'ROOT': 'root', + 'TOS': 'tos', + 'HONOR': 'honor', + 'PRIVACY': 'privacy_edx', +} + +############################### THEME ################################ +def enable_theme(theme_name): + """ + Enable the settings for a custom theme, whose files should be stored + in ENV_ROOT/themes/THEME_NAME (e.g., edx_all/themes/stanford). + + The THEME_NAME setting should be configured separately since it can't + be set here (this function closes too early). An idiom for doing this + is: + + THEME_NAME = "stanford" + enable_theme(THEME_NAME) + """ + MITX_FEATURES['USE_CUSTOM_THEME'] = True + + # Calculate the location of the theme's files + theme_root = ENV_ROOT / "themes" / theme_name + + # Include the theme's templates in the template search paths + TEMPLATE_DIRS.append(theme_root / 'templates') + MAKO_TEMPLATES['main'].append(theme_root / 'templates') + + # Namespace the theme's static files to 'themes/' to + # avoid collisions with default edX static files + STATICFILES_DIRS.append((u'themes/%s' % theme_name, + theme_root / 'static')) + diff --git a/lms/envs/content.py b/lms/envs/content.py index f699153895..f85ae0b9cd 100644 --- a/lms/envs/content.py +++ b/lms/envs/content.py @@ -2,6 +2,11 @@ These are debug machines used for content creators, so they're kind of a cross between dev machines and AWS machines. """ + +# We intentionally define lots of variables that aren't used, and +# want to import all variables from base settings files +# pylint: disable=W0401, W0614 + from .aws import * DEBUG = True diff --git a/lms/envs/dev.py b/lms/envs/dev.py index 8363f744a0..9d7c0b3ac2 100644 --- a/lms/envs/dev.py +++ b/lms/envs/dev.py @@ -7,6 +7,11 @@ sessions. Assumes structure: /mitx # The location of this repo /log # Where we're going to write log files """ + +# We intentionally define lots of variables that aren't used, and +# want to import all variables from base settings files +# pylint: disable=W0401, W0614 + from .common import * from logsettings import get_logger_config @@ -22,8 +27,7 @@ MITX_FEATURES['FORCE_UNIVERSITY_DOMAIN'] = None # show all university courses i MITX_FEATURES['ENABLE_MANUAL_GIT_RELOAD'] = True MITX_FEATURES['ENABLE_PSYCHOMETRICS'] = False # real-time psychometrics (eg item response theory analysis in instructor dashboard) MITX_FEATURES['ENABLE_INSTRUCTOR_ANALYTICS'] = True - - +MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True WIKI_ENABLED = True @@ -143,7 +147,7 @@ if os.path.isdir(DATA_DIR): MITX_VERSION_STRING = os.popen('cd %s; git describe' % REPO_ROOT).read().strip() -################################# Open ended grading config ##################### +############################ Open ended grading config ##################### OPEN_ENDED_GRADING_INTERFACE = { 'url' : 'http://127.0.0.1:3033/', @@ -154,7 +158,7 @@ OPEN_ENDED_GRADING_INTERFACE = { 'grading_controller' : 'grading_controller' } -################################ LMS Migration ################################# +############################## LMS Migration ################################## MITX_FEATURES['ENABLE_LMS_MIGRATION'] = True MITX_FEATURES['ACCESS_REQUIRE_STAFF_FOR_COURSE'] = False # require that user be in the staff_* group to be able to enroll MITX_FEATURES['USE_XQA_SERVER'] = 'http://xqa:server@content-qa.mitx.mit.edu/xqa' @@ -164,6 +168,7 @@ INSTALLED_APPS += ('lms_migration',) LMS_MIGRATION_ALLOWED_IPS = ['127.0.0.1'] ################################ OpenID Auth ################################# + MITX_FEATURES['AUTH_USE_OPENID'] = True MITX_FEATURES['AUTH_USE_OPENID_PROVIDER'] = True MITX_FEATURES['BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH'] = True @@ -173,16 +178,22 @@ INSTALLED_APPS += ('django_openid_auth',) OPENID_CREATE_USERS = False OPENID_UPDATE_DETAILS_FROM_SREG = True -OPENID_SSO_SERVER_URL = 'https://www.google.com/accounts/o8/id' # TODO: accept more endpoints +OPENID_SSO_SERVER_URL = 'https://www.google.com/accounts/o8/id' # TODO: accept more endpoints OPENID_USE_AS_ADMIN_LOGIN = False OPENID_PROVIDER_TRUSTED_ROOTS = ['*'] -################################ MIT Certificates SSL Auth ################################# +######################## MIT Certificates SSL Auth ############################ MITX_FEATURES['AUTH_USE_MIT_CERTIFICATES'] = True -################################ DEBUG TOOLBAR ################################# +################################# CELERY ###################################### + +# By default don't use a worker, execute tasks as if they were local functions +CELERY_ALWAYS_EAGER = True + +################################ DEBUG TOOLBAR ################################ + INSTALLED_APPS += ('debug_toolbar',) MIDDLEWARE_CLASSES += ('django_comment_client.utils.QueryCountDebugMiddleware', 'debug_toolbar.middleware.DebugToolbarMiddleware',) @@ -208,7 +219,9 @@ DEBUG_TOOLBAR_PANELS = ( DEBUG_TOOLBAR_CONFIG = { 'INTERCEPT_REDIRECTS': False } -############################ FILE UPLOADS (for discussion forums) ############################# + +#################### FILE UPLOADS (for discussion forums) ##################### + DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage' MEDIA_ROOT = ENV_ROOT / "uploads" MEDIA_URL = "/static/uploads/" @@ -224,9 +237,7 @@ FILE_UPLOAD_HANDLERS = ( PIPELINE_SASS_ARGUMENTS = '--debug-info --require {proj_dir}/static/sass/bourbon/lib/bourbon.rb'.format(proj_dir=PROJECT_ROOT) ########################## PEARSON TESTING ########################### -MITX_FEATURES['ENABLE_PEARSON_HACK_TEST'] = True -PEARSON_TEST_USER = "pearsontest" -PEARSON_TEST_PASSWORD = "12345" +MITX_FEATURES['ENABLE_PEARSON_LOGIN'] = False ########################## ANALYTICS TESTING ######################## diff --git a/lms/envs/dev_edx4edx.py b/lms/envs/dev_edx4edx.py index 2ebd24e68b..c90f369bc6 100644 --- a/lms/envs/dev_edx4edx.py +++ b/lms/envs/dev_edx4edx.py @@ -8,6 +8,10 @@ sessions. Assumes structure: /log # Where we're going to write log files """ +# We intentionally define lots of variables that aren't used, and +# want to import all variables from base settings files +# pylint: disable=W0401, W0614 + import socket if 'eecs1' in socket.gethostname(): diff --git a/lms/envs/dev_ike.py b/lms/envs/dev_ike.py index 639d186989..3f54b11d1e 100644 --- a/lms/envs/dev_ike.py +++ b/lms/envs/dev_ike.py @@ -7,6 +7,11 @@ sessions. Assumes structure: /mitx # The location of this repo /log # Where we're going to write log files """ + +# We intentionally define lots of variables that aren't used, and +# want to import all variables from base settings files +# pylint: disable=W0401, W0614 + from .common import * from logsettings import get_logger_config from .dev import * diff --git a/lms/envs/dev_int.py b/lms/envs/dev_int.py index 21c52c8abc..34921205a6 100644 --- a/lms/envs/dev_int.py +++ b/lms/envs/dev_int.py @@ -9,6 +9,11 @@ following domains to 127.0.0.1 in your /etc/hosts file: Note that OS X has a bug where using *.local domains is excruciatingly slow, so use *.dev domains instead for local testing. """ + +# We intentionally define lots of variables that aren't used, and +# want to import all variables from base settings files +# pylint: disable=W0401, W0614 + from .dev import * MITX_FEATURES['SUBDOMAIN_COURSE_LISTINGS'] = True diff --git a/lms/envs/dev_mongo.py b/lms/envs/dev_mongo.py index 6af0a429bb..dfbf473b45 100644 --- a/lms/envs/dev_mongo.py +++ b/lms/envs/dev_mongo.py @@ -1,6 +1,11 @@ """ This config file runs the dev environment, but with mongo as the datastore """ + +# We intentionally define lots of variables that aren't used, and +# want to import all variables from base settings files +# pylint: disable=W0401, W0614 + from .dev import * GITHUB_REPO_ROOT = ENV_ROOT / "data" diff --git a/lms/envs/dev_with_worker.py b/lms/envs/dev_with_worker.py new file mode 100644 index 0000000000..078567c493 --- /dev/null +++ b/lms/envs/dev_with_worker.py @@ -0,0 +1,39 @@ +""" +This config file follows the dev enviroment, but adds the +requirement of a celery worker running in the background to process +celery tasks. + +The worker can be executed using: + +django_admin.py celery worker +""" + +# We intentionally define lots of variables that aren't used, and +# want to import all variables from base settings files +# pylint: disable=W0401, W0614 + +from dev import * + +################################# CELERY ###################################### + +# Requires a separate celery worker + +CELERY_ALWAYS_EAGER = False + +# Use django db as the broker and result store + +BROKER_URL = 'django://' +INSTALLED_APPS += ('djcelery.transport', ) +CELERY_RESULT_BACKEND = 'database' +DJKOMBU_POLLING_INTERVAL = 1.0 + +# Disable transaction management because we are using a worker. Views +# that request a task and wait for the result will deadlock otherwise. + +MIDDLEWARE_CLASSES = tuple( + c for c in MIDDLEWARE_CLASSES + if c != 'django.middleware.transaction.TransactionMiddleware') + +# Note: other alternatives for disabling transactions don't work in 1.4 +# https://code.djangoproject.com/ticket/2304 +# https://code.djangoproject.com/ticket/16039 diff --git a/lms/envs/devgroups/courses.py b/lms/envs/devgroups/courses.py index c44717c451..1a7ff58f08 100644 --- a/lms/envs/devgroups/courses.py +++ b/lms/envs/devgroups/courses.py @@ -1,3 +1,8 @@ + +# We intentionally define lots of variables that aren't used, and +# want to import all variables from base settings files +# pylint: disable=W0401, W0614 + from ..dev import * CLASSES_TO_DBS = { diff --git a/lms/envs/devgroups/h_cs50.py b/lms/envs/devgroups/h_cs50.py index 9643c33d35..21c959f5ce 100644 --- a/lms/envs/devgroups/h_cs50.py +++ b/lms/envs/devgroups/h_cs50.py @@ -1,3 +1,8 @@ + +# We intentionally define lots of variables that aren't used, and +# want to import all variables from base settings files +# pylint: disable=W0401, W0614 + from .courses import * DATABASES = course_db_for('HarvardX/CS50x/2012') diff --git a/lms/envs/devgroups/m_6002.py b/lms/envs/devgroups/m_6002.py index 411e2bcc3c..d3c10fcd04 100644 --- a/lms/envs/devgroups/m_6002.py +++ b/lms/envs/devgroups/m_6002.py @@ -1,3 +1,8 @@ + +# We intentionally define lots of variables that aren't used, and +# want to import all variables from base settings files +# pylint: disable=W0401, W0614 + from .courses import * DATABASES = course_db_for('MITx/6.002x/2012_Fall') diff --git a/lms/envs/devgroups/portal.py b/lms/envs/devgroups/portal.py index 35808d56fa..8e4635cc66 100644 --- a/lms/envs/devgroups/portal.py +++ b/lms/envs/devgroups/portal.py @@ -2,6 +2,11 @@ Note that for this to work at all, you must have memcached running (or you won't get shared sessions) """ + +# We intentionally define lots of variables that aren't used, and +# want to import all variables from base settings files +# pylint: disable=W0401, W0614 + from courses import * # Move this to a shared file later: diff --git a/lms/envs/devplus.py b/lms/envs/devplus.py index ea6590291c..bfd0788165 100644 --- a/lms/envs/devplus.py +++ b/lms/envs/devplus.py @@ -13,6 +13,11 @@ Dir structure: /log # Where we're going to write log files """ + +# We intentionally define lots of variables that aren't used, and +# want to import all variables from base settings files +# pylint: disable=W0401, W0614 + from .dev import * WIKI_ENABLED = True diff --git a/lms/envs/discussionsettings.py b/lms/envs/discussionsettings.py index f13680a7fe..1ac4c23af8 100644 --- a/lms/envs/discussionsettings.py +++ b/lms/envs/discussionsettings.py @@ -1 +1,5 @@ + +# We intentionally define variables that aren't used +# pylint: disable=W0614 + DISCUSSION_ALLOWED_UPLOAD_FILE_TYPES = ('.jpg', '.jpeg', '.gif', '.bmp', '.png', '.tiff') diff --git a/lms/envs/edx4edx_aws.py b/lms/envs/edx4edx_aws.py index b82048824f..247fa866bc 100644 --- a/lms/envs/edx4edx_aws.py +++ b/lms/envs/edx4edx_aws.py @@ -1,3 +1,7 @@ +# We intentionally define lots of variables that aren't used, and +# want to import all variables from base settings files +# pylint: disable=W0401, W0614 + # Settings for edx4edx production instance from .aws import * COURSE_NAME = "edx4edx" diff --git a/lms/envs/jasmine.py b/lms/envs/jasmine.py index 8551d80504..4a78ed8075 100644 --- a/lms/envs/jasmine.py +++ b/lms/envs/jasmine.py @@ -2,6 +2,10 @@ This configuration is used for running jasmine tests """ +# We intentionally define lots of variables that aren't used, and +# want to import all variables from base settings files +# pylint: disable=W0401, W0614 + from .test import * from logsettings import get_logger_config @@ -20,19 +24,24 @@ PIPELINE_JS['js-test-source'] = { 'source_filenames': sum([ pipeline_group['source_filenames'] for group_name, pipeline_group - in PIPELINE_JS.items() + in sorted(PIPELINE_JS.items(), key=lambda item: item[1].get('test_order', 1e100)) if group_name != 'spec' ], []), 'output_filename': 'js/lms-test-source.js' } PIPELINE_JS['spec'] = { - 'source_filenames': sorted(rooted_glob(PROJECT_ROOT / 'static/', 'coffee/spec/**/*.coffee')), + 'source_filenames': sorted(rooted_glob(PROJECT_ROOT / 'static/', 'coffee/spec/**/*.js')), 'output_filename': 'js/lms-spec.js' } JASMINE_TEST_DIRECTORY = PROJECT_ROOT + '/static/coffee' +JASMINE_REPORT_DIR = os.environ.get('JASMINE_REPORT_DIR', 'reports/lms/jasmine') -STATICFILES_DIRS.append(COMMON_ROOT / 'test' / 'phantom-jasmine' / 'lib') +TEMPLATE_CONTEXT_PROCESSORS += ('settings_context_processor.context_processors.settings',) +TEMPLATE_VISIBLE_SETTINGS = ('JASMINE_REPORT_DIR', ) -INSTALLED_APPS += ('django_jasmine', ) +STATICFILES_DIRS.append(REPO_ROOT/'node_modules/phantom-jasmine/lib') +STATICFILES_DIRS.append(REPO_ROOT/'node_modules/jasmine-reporters/src') + +INSTALLED_APPS += ('django_jasmine', 'settings_context_processor') diff --git a/lms/envs/static.py b/lms/envs/static.py index 23e735c747..260153e623 100644 --- a/lms/envs/static.py +++ b/lms/envs/static.py @@ -7,6 +7,11 @@ sessions. Assumes structure: /mitx # The location of this repo /log # Where we're going to write log files """ + +# We intentionally define lots of variables that aren't used, and +# want to import all variables from base settings files +# pylint: disable=W0401, W0614 + from .common import * from logsettings import get_logger_config diff --git a/lms/envs/test.py b/lms/envs/test.py index 5eb96c8df0..6691d50106 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -7,6 +7,11 @@ sessions. Assumes structure: /mitx # The location of this repo /log # Where we're going to write log files """ + +# We intentionally define lots of variables that aren't used, and +# want to import all variables from base settings files +# pylint: disable=W0401, W0614 + from .common import * import os from path import path @@ -16,7 +21,9 @@ from path import path MITX_FEATURES['DISABLE_START_DATES'] = True # Until we have discussion actually working in test mode, just turn it off -MITX_FEATURES['ENABLE_DISCUSSION_SERVICE'] = False +MITX_FEATURES['ENABLE_DISCUSSION_SERVICE'] = True + +MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True # Need wiki for courseware views to work. TODO (vshnayder): shouldn't need it. WIKI_ENABLED = True @@ -27,10 +34,6 @@ SOUTH_TESTS_MIGRATE = False # To disable migrations and use syncdb instead # Nose Test Runner INSTALLED_APPS += ('django_nose',) -NOSE_ARGS = [ - '--with-xunit', - # '-v', '--pdb', # When really stuck, uncomment to start debugger on error -] TEST_RUNNER = 'django_nose.NoseTestSuiteRunner' # Local Directories @@ -91,7 +94,7 @@ MODULESTORE = { DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': PROJECT_ROOT / "db" / "mitx.db", + 'NAME': TEST_ROOT / 'db' / 'mitx.db' }, } @@ -122,13 +125,13 @@ CACHES = { 'LOCATION': '/var/tmp/mongo_metadata_inheritance', 'TIMEOUT': 300, 'KEY_FUNCTION': 'util.memcache.safe_key', - } + } } # Dummy secret key for dev SECRET_KEY = '85920908f28904ed733fe576320db18cabd7b6cd' -################################## OPENID ###################################### +################################## OPENID ##################################### MITX_FEATURES['AUTH_USE_OPENID'] = True MITX_FEATURES['AUTH_USE_OPENID_PROVIDER'] = True @@ -140,6 +143,12 @@ OPENID_PROVIDER_TRUSTED_ROOTS = ['*'] INSTALLED_APPS += ('external_auth',) INSTALLED_APPS += ('django_openid_auth',) +################################# CELERY ###################################### + +CELERY_ALWAYS_EAGER = True +CELERY_RESULT_BACKEND = 'cache' +BROKER_TRANSPORT = 'memory' + ############################ STATIC FILES ############################# DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage' MEDIA_ROOT = TEST_ROOT / "uploads" diff --git a/lms/envs/test_ike.py b/lms/envs/test_ike.py deleted file mode 100644 index 907b7eeadf..0000000000 --- a/lms/envs/test_ike.py +++ /dev/null @@ -1,87 +0,0 @@ -""" -This config file runs the simplest dev environment using sqlite, and db-based -sessions. Assumes structure: - -/envroot/ - /db # This is where it'll write the database file - /mitx # The location of this repo - /log # Where we're going to write log files -""" -from .common import * -from logsettings import get_logger_config -import os - -DEBUG = True - -INSTALLED_APPS = [ - app - for app - in INSTALLED_APPS -] - -# Nose Test Runner -INSTALLED_APPS += ['django_nose'] -#NOSE_ARGS = ['--cover-erase', '--with-xunit', '--with-xcoverage', '--cover-html', '--cover-inclusive'] -NOSE_ARGS = ['--cover-erase', '--with-xunit', '--cover-html', '--cover-inclusive'] -for app in os.listdir(PROJECT_ROOT / 'djangoapps'): - NOSE_ARGS += ['--cover-package', app] -TEST_RUNNER = 'django_nose.NoseTestSuiteRunner' - -# Local Directories -TEST_ROOT = path("test_root") -COURSES_ROOT = TEST_ROOT / "data" -DATA_DIR = COURSES_ROOT -MAKO_TEMPLATES['course'] = [DATA_DIR] -MAKO_TEMPLATES['sections'] = [DATA_DIR / 'sections'] -MAKO_TEMPLATES['custom_tags'] = [DATA_DIR / 'custom_tags'] -MAKO_TEMPLATES['main'] = [PROJECT_ROOT / 'templates', - DATA_DIR / 'info', - DATA_DIR / 'problems'] - -LOGGING = get_logger_config(TEST_ROOT / "log", - logging_env="dev", - tracking_filename="tracking.log", - debug=True) - -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': PROJECT_ROOT / "db" / "mitx.db", - } -} - -CACHES = { - # This is the cache used for most things. - # In staging/prod envs, the sessions also live here. - 'default': { - 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', - 'LOCATION': 'mitx_loc_mem_cache', - 'KEY_FUNCTION': 'util.memcache.safe_key', - }, - - # The general cache is what you get if you use our util.cache. It's used for - # things like caching the course.xml file for different A/B test groups. - # We set it to be a DummyCache to force reloading of course.xml in dev. - # In staging environments, we would grab VERSION from data uploaded by the - # push process. - 'general': { - 'BACKEND': 'django.core.cache.backends.dummy.DummyCache', - 'KEY_PREFIX': 'general', - 'VERSION': 4, - 'KEY_FUNCTION': 'util.memcache.safe_key', - } -} - -# Dummy secret key for dev -SECRET_KEY = '85920908f28904ed733fe576320db18cabd7b6cd' - -############################ FILE UPLOADS (for discussion forums) ############################# -DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage' -MEDIA_ROOT = PROJECT_ROOT / "uploads" -MEDIA_URL = "/static/uploads/" -STATICFILES_DIRS.append(("uploads", MEDIA_ROOT)) -FILE_UPLOAD_TEMP_DIR = PROJECT_ROOT / "uploads" -FILE_UPLOAD_HANDLERS = ( - 'django.core.files.uploadhandler.MemoryFileUploadHandler', - 'django.core.files.uploadhandler.TemporaryFileUploadHandler', -) diff --git a/lms/lib/comment_client/comment.py b/lms/lib/comment_client/comment.py index 2f93aff6b3..fb5a4ad0c3 100644 --- a/lms/lib/comment_client/comment.py +++ b/lms/lib/comment_client/comment.py @@ -11,12 +11,12 @@ class Comment(models.Model): 'id', 'body', 'anonymous', 'anonymous_to_peers', 'course_id', 'endorsed', 'parent_id', 'thread_id', 'username', 'votes', 'user_id', 'closed', 'created_at', 'updated_at', 'depth', 'at_position_list', - 'type', 'commentable_id', + 'type', 'commentable_id', 'abuse_flaggers' ] updatable_fields = [ 'body', 'anonymous', 'anonymous_to_peers', 'course_id', 'closed', - 'user_id', 'endorsed', + 'user_id', 'endorsed' ] initializable_fields = updatable_fields @@ -42,6 +42,32 @@ class Comment(models.Model): else: return super(Comment, cls).url(action, params) + def flagAbuse(self, user, voteable): + if voteable.type == 'thread': + url = _url_for_flag_abuse_thread(voteable.id) + elif voteable.type == 'comment': + url = _url_for_flag_abuse_comment(voteable.id) + else: + raise CommentClientError("Can only flag/unflag threads or comments") + params = {'user_id': user.id} + request = perform_request('put', url, params) + voteable.update_attributes(request) + + def unFlagAbuse(self, user, voteable, removeAll): + if voteable.type == 'thread': + url = _url_for_unflag_abuse_thread(voteable.id) + elif voteable.type == 'comment': + url = _url_for_unflag_abuse_comment(voteable.id) + else: + raise CommentClientError("Can flag/unflag for threads or comments") + params = {'user_id': user.id} + + if removeAll: + params['all'] = True + + request = perform_request('put', url, params) + voteable.update_attributes(request) + def _url_for_thread_comments(thread_id): return "{prefix}/threads/{thread_id}/comments".format(prefix=settings.PREFIX, thread_id=thread_id) @@ -49,3 +75,11 @@ def _url_for_thread_comments(thread_id): def _url_for_comment(comment_id): return "{prefix}/comments/{comment_id}".format(prefix=settings.PREFIX, comment_id=comment_id) + + +def _url_for_flag_abuse_comment(comment_id): + return "{prefix}/comments/{comment_id}/abuse_flag".format(prefix=settings.PREFIX, comment_id=comment_id) + + +def _url_for_unflag_abuse_comment(comment_id): + return "{prefix}/comments/{comment_id}/abuse_unflag".format(prefix=settings.PREFIX, comment_id=comment_id) diff --git a/lms/lib/comment_client/comment_client.py b/lms/lib/comment_client/comment_client.py index 862483a75b..9b1a0baee2 100644 --- a/lms/lib/comment_client/comment_client.py +++ b/lms/lib/comment_client/comment_client.py @@ -29,7 +29,6 @@ def search_trending_tags(course_id, query_params={}, *args, **kwargs): def tags_autocomplete(value, *args, **kwargs): return perform_request('get', _url_for_threads_tags_autocomplete(), {'value': value}, *args, **kwargs) - def _url_for_search_similar_threads(): return "{prefix}/search/threads/more_like_this".format(prefix=settings.PREFIX) diff --git a/lms/lib/comment_client/thread.py b/lms/lib/comment_client/thread.py index 8911d5a2c6..0b0be576b8 100644 --- a/lms/lib/comment_client/thread.py +++ b/lms/lib/comment_client/thread.py @@ -1,5 +1,4 @@ from .utils import * - import models import settings @@ -11,7 +10,7 @@ class Thread(models.Model): 'closed', 'tags', 'votes', 'commentable_id', 'username', 'user_id', 'created_at', 'updated_at', 'comments_count', 'unread_comments_count', 'at_position_list', 'children', 'type', 'highlighted_title', - 'highlighted_body', 'endorsed', 'read', 'group_id', 'group_name', 'pinned' + 'highlighted_body', 'endorsed', 'read', 'group_id', 'group_name', 'pinned', 'abuse_flaggers' ] updatable_fields = [ @@ -27,11 +26,13 @@ class Thread(models.Model): @classmethod def search(cls, query_params, *args, **kwargs): + default_params = {'page': 1, 'per_page': 20, 'course_id': query_params['course_id'], 'recursive': False} params = merge_dict(default_params, strip_blank(strip_none(query_params))) + if query_params.get('text') or query_params.get('tags') or query_params.get('commentable_ids'): url = cls.url(action='search') else: @@ -54,6 +55,7 @@ class Thread(models.Model): @classmethod def url(cls, action, params={}): + if action in ['get_all', 'post']: return cls.url_for_threads(params) elif action == 'search': @@ -66,12 +68,11 @@ class Thread(models.Model): # that subclasses don't need to override for this. def _retrieve(self, *args, **kwargs): url = self.url(action='get', params=self.attributes) - request_params = { - 'recursive': kwargs.get('recursive'), - 'user_id': kwargs.get('user_id'), - 'mark_as_read': kwargs.get('mark_as_read', True), - } + 'recursive': kwargs.get('recursive'), + 'user_id': kwargs.get('user_id'), + 'mark_as_read': kwargs.get('mark_as_read', True), + } # user_id may be none, in which case it shouldn't be part of the # request. @@ -79,23 +80,57 @@ class Thread(models.Model): response = perform_request('get', url, request_params) self.update_attributes(**response) - + + def flagAbuse(self, user, voteable): + if voteable.type == 'thread': + url = _url_for_flag_abuse_thread(voteable.id) + elif voteable.type == 'comment': + url = _url_for_flag_comment(voteable.id) + else: + raise CommentClientError("Can only flag/unflag threads or comments") + params = {'user_id': user.id} + request = perform_request('put', url, params) + voteable.update_attributes(request) + + def unFlagAbuse(self, user, voteable, removeAll): + if voteable.type == 'thread': + url = _url_for_unflag_abuse_thread(voteable.id) + elif voteable.type == 'comment': + url = _url_for_unflag_comment(voteable.id) + else: + raise CommentClientError("Can only flag/unflag for threads or comments") + params = {'user_id': user.id} + #if you're an admin, when you unflag, remove ALL flags + if removeAll: + params['all'] = True + + request = perform_request('put', url, params) + voteable.update_attributes(request) + def pin(self, user, thread_id): url = _url_for_pin_thread(thread_id) params = {'user_id': user.id} request = perform_request('put', url, params) - self.update_attributes(request) + self.update_attributes(request) def un_pin(self, user, thread_id): url = _url_for_un_pin_thread(thread_id) params = {'user_id': user.id} request = perform_request('put', url, params) - self.update_attributes(request) - - + self.update_attributes(request) + + +def _url_for_flag_abuse_thread(thread_id): + return "{prefix}/threads/{thread_id}/abuse_flag".format(prefix=settings.PREFIX, thread_id=thread_id) + + +def _url_for_unflag_abuse_thread(thread_id): + return "{prefix}/threads/{thread_id}/abuse_unflag".format(prefix=settings.PREFIX, thread_id=thread_id) + + def _url_for_pin_thread(thread_id): - return "{prefix}/threads/{thread_id}/pin".format(prefix=settings.PREFIX, thread_id=thread_id) - + return "{prefix}/threads/{thread_id}/pin".format(prefix=settings.PREFIX, thread_id=thread_id) + + def _url_for_un_pin_thread(thread_id): - return "{prefix}/threads/{thread_id}/unpin".format(prefix=settings.PREFIX, thread_id=thread_id) - \ No newline at end of file + return "{prefix}/threads/{thread_id}/unpin".format(prefix=settings.PREFIX, thread_id=thread_id) diff --git a/lms/lib/comment_client/utils.py b/lms/lib/comment_client/utils.py index 860035dc06..0ff06fced7 100644 --- a/lms/lib/comment_client/utils.py +++ b/lms/lib/comment_client/utils.py @@ -1,3 +1,4 @@ +from dogapi import dog_stats_api import json import logging import requests @@ -32,11 +33,16 @@ def perform_request(method, url, data_or_params=None, *args, **kwargs): data_or_params = {} data_or_params['api_key'] = settings.API_KEY try: - if method in ['post', 'put', 'patch']: - response = requests.request(method, url, data=data_or_params, timeout=5) - else: - response = requests.request(method, url, params=data_or_params, timeout=5) + with dog_stats_api.timer('comment_client.request.time'): + if method in ['post', 'put', 'patch']: + response = requests.request(method, url, data=data_or_params, timeout=5) + else: + response = requests.request(method, url, params=data_or_params, timeout=5) except Exception as err: + # remove API key if it is in the params + if 'api_key' in data_or_params: + log.info('Deleting API key from params') + del data_or_params['api_key'] log.exception("Trying to call {method} on {url} with params {params}".format( method=method, url=url, params=data_or_params)) # Reraise with a single exception type @@ -44,6 +50,9 @@ def perform_request(method, url, data_or_params=None, *args, **kwargs): if 200 < response.status_code < 500: raise CommentClientError(response.text) + # Heroku returns a 503 when an application is in maintenance mode + elif response.status_code == 503: + raise CommentClientMaintenanceError(response.text) elif response.status_code == 500: raise CommentClientUnknownError(response.text) else: @@ -61,5 +70,9 @@ class CommentClientError(Exception): return repr(self.message) +class CommentClientMaintenanceError(CommentClientError): + pass + + class CommentClientUnknownError(CommentClientError): pass diff --git a/lms/static/coffee/.gitignore b/lms/static/coffee/.gitignore index bb90193362..a6c7c2852d 100644 --- a/lms/static/coffee/.gitignore +++ b/lms/static/coffee/.gitignore @@ -1,2 +1 @@ *.js -module diff --git a/lms/static/coffee/files.json b/lms/static/coffee/files.json index 0efe488dd9..d9d7087efe 100644 --- a/lms/static/coffee/files.json +++ b/lms/static/coffee/files.json @@ -1,8 +1,5 @@ { "js_files": [ - "/static/js/vendor/RequireJS.js", - "/static/js/vendor/jquery.min.js", - "/static/js/vendor/jquery-ui.min.js", "/static/js/vendor/jquery.leanModal.min.js", "/static/js/vendor/flot/jquery.flot.js" ] diff --git a/lms/static/coffee/src/notes.coffee b/lms/static/coffee/src/notes.coffee new file mode 100644 index 0000000000..e13707256e --- /dev/null +++ b/lms/static/coffee/src/notes.coffee @@ -0,0 +1,73 @@ +class StudentNotes + _debug: false + + targets: [] # holds elements with annotator() instances + + # Adds a listener for "notes" events that may bubble up from descendants. + constructor: ($, el) -> + console.log 'student notes init', arguments, this if @_debug + + if not $(el).data('notes-instance') + events = 'notes:init': @onInitNotes + $(el).delegate('*', events) + $(el).data('notes-instance', @) + + # Initializes annotations on a container element in response to an init event. + onInitNotes: (event, uri=null) => + event.stopPropagation() + + storeConfig = @getStoreConfig uri + found = @targets.some (target) -> target is event.target + + if found + annotator = $(event.target).data('annotator') + if annotator + store = annotator.plugins['Store'] + $.extend(store.options, storeConfig) + if uri + store.loadAnnotationsFromSearch(storeConfig['loadFromSearch']) + else + console.log 'URI is required to load annotations' + else + console.log 'No annotator() instance found for target: ', event.target + else + $(event.target).annotator() + .annotator('addPlugin', 'Tags') + .annotator('addPlugin', 'Store', storeConfig) + @targets.push(event.target) + + # Returns a JSON config object that can be passed to the annotator Store plugin + getStoreConfig: (uri) -> + prefix = @getPrefix() + if uri is null + uri = @getURIPath() + + storeConfig = + prefix: prefix + loadFromSearch: + uri: uri + limit: 0 + annotationData: + uri: uri + storeConfig + + # Returns the API endpoint for the annotation store + getPrefix: () -> + re = /^(\/courses\/[^/]+\/[^/]+\/[^/]+)/ + match = re.exec(@getURIPath()) + prefix = (if match then match[1] else '') + return "#{prefix}/notes/api" + + # Returns the URI path of the current page for filtering annotations + getURIPath: () -> + window.location.href.toString().split(window.location.host)[1] + + +# Enable notes by default on the document root. +# To initialize annotations on a container element in the document: +# +# $('#myElement').trigger('notes:init'); +# +# Comment this line to disable notes. + +$(document).ready ($) -> new StudentNotes $, @ diff --git a/lms/static/coffee/src/staff_grading/staff_grading.coffee b/lms/static/coffee/src/staff_grading/staff_grading.coffee index 6af9ecf5d1..f4a3360d1e 100644 --- a/lms/static/coffee/src/staff_grading/staff_grading.coffee +++ b/lms/static/coffee/src/staff_grading/staff_grading.coffee @@ -185,6 +185,7 @@ class @StaffGrading $(window).keydown @keydown_handler + $(window).keyup @keyup_handler @question_header = $('.question-header') @question_header.click @collapse_question @collapse_question() @@ -206,6 +207,7 @@ class @StaffGrading @num_pending = 0 @score_lst = [] @grade = null + @is_ctrl = false @problems = null @@ -231,10 +233,18 @@ class @StaffGrading @state = state_graded @submit_button.show() - keydown_handler: (e) => - if e.which == 13 && !@list_view && Rubric.check_complete() + keydown_handler: (event) => + #Previously, responses were submitted when hitting enter. Add in a modifier that ensures that ctrl+enter is needed. + if event.which == 17 && @is_ctrl==false + @is_ctrl=true + else if @is_ctrl==true && event.which == 13 && !@list_view && Rubric.check_complete() @submit_and_get_next() + keyup_handler: (event) => + #Handle keyup event when ctrl key is released + if event.which == 17 && @is_ctrl==true + @is_ctrl=false + set_button_text: (text) => @action_button.attr('value', text) diff --git a/lms/static/css/vendor/annotator.css b/lms/static/css/vendor/annotator.css new file mode 100644 index 0000000000..b3af816775 --- /dev/null +++ b/lms/static/css/vendor/annotator.css @@ -0,0 +1,899 @@ +/* Base Reset +-------------------------------------------------------------------- */ + +.annotator-notice, +.annotator-filter *, +.annotator-widget * { + font-family: "Helvetica Neue", Arial, Helvetica, sans-serif; + font-weight: normal; + text-align: left; + margin: 0; + padding: 0; + background: none; + -webkit-transition: none; + -moz-transition: none; + -o-transition: none; + transition: none; + -moz-box-shadow: none; + -webkit-box-shadow: none; + -o-box-shadow: none; + box-shadow: none; + color: rgb(144, 144, 144); +} + +/* Images +-------------------------------------------------------------------- */ + +.annotator-adder { + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJAAAAAwCAYAAAD+WvNWAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAA2ZpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMC1jMDYwIDYxLjEzNDc3NywgMjAxMC8wMi8xMi0xNzozMjowMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0ieG1wLmRpZDowMzgwMTE3NDA3MjA2ODExODRCQUU5RDY0RTkyQTJDNiIgeG1wTU06RG9jdW1lbnRJRD0ieG1wLmRpZDowOUY5RUFERDYwOEIxMUUxOTQ1RDkyQzU2OTNEMDZENCIgeG1wTU06SW5zdGFuY2VJRD0ieG1wLmlpZDowOUY5RUFEQzYwOEIxMUUxOTQ1RDkyQzU2OTNEMDZENCIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ1M1IE1hY2ludG9zaCI+IDx4bXBNTTpEZXJpdmVkRnJvbSBzdFJlZjppbnN0YW5jZUlEPSJ4bXAuaWlkOjA1ODAxMTc0MDcyMDY4MTE5MTA5OUIyNDhFRUQ1QkM4IiBzdFJlZjpkb2N1bWVudElEPSJ4bXAuZGlkOjAzODAxMTc0MDcyMDY4MTE4NEJBRTlENjRFOTJBMkM2Ii8+IDwvcmRmOkRlc2NyaXB0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'); + background-repeat: no-repeat; +} + +.annotator-resize, +.annotator-widget::after, +.annotator-editor a::after, +.annotator-viewer .annotator-controls button, +.annotator-viewer .annotator-controls a, +.annotator-filter .annotator-filter-navigation button::after, +.annotator-filter .annotator-filter-property .annotator-filter-clear { + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAEiCAYAAAD0w4JOAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMC1jMDYwIDYxLjEzNDc3NywgMjAxMC8wMi8xMi0xNzozMjowMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNSBNYWNpbnRvc2giIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6RDY0MTMzNTM2QUQzMTFFMUE2REJERDgwQTM3Njg5NTUiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6RDY0MTMzNTQ2QUQzMTFFMUE2REJERDgwQTM3Njg5NTUiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo2ODkwQjlFQzZBRDExMUUxQTZEQkREODBBMzc2ODk1NSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDpENjQxMzM1MjZBRDMxMUUxQTZEQkREODBBMzc2ODk1NSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PkijPpwAABBRSURBVHja7JsJVBRX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'); + background-repeat: no-repeat; +} + +/* Annotator Highlight +-------------------------------------------------------------------- */ + +.annotator-hl { + background: rgba(255, 255, 10, 0.3); +} + +.annotator-hl-temporary { + background: rgba(0, 124, 255, 0.3); +} + +/* Annotator Wrapper +-------------------------------------------------------------------- */ + +.annotator-wrapper { + position: relative; +} + +/* NB: If you change the list of classes for which z-index is set, + you should update Annotator._setupDynamicStyle() */ +.annotator-adder, +.annotator-outer, +.annotator-notice { + z-index: 1020; +} + +.annotator-filter { + z-index: 1010; +} + +.annotator-adder, +.annotator-outer, +.annotator-widget, +.annotator-notice { + position: absolute; + font-size: 10px; + line-height: 1; +} + +.annotator-hide { + display: none; + visibility: hidden; +} + +/* Annotator Adder +-------------------------------------------------------------------- */ + +.annotator-adder { + margin-top: -48px; + margin-left: -24px; + width: 48px; + height: 48px; + background-position: left top; +} + +.annotator-adder:hover { + background-position: center top; +} + +.annotator-adder:active { + background-position: center right; +} + +.annotator-adder button { + display: block; + width: 36px; + height: 41px; + margin: 0 auto; + border: none; + background: none; + text-indent: -999em; + cursor: pointer; +} + +/** NOTE: fix for conflict with course.css */ +.annotator-adder button:hover, +.annotator-adder button:active { + background-color: inherit; + -webkit-box-shadow: inherit; + -moz-box-shadow: inherit; + box-shadow: inherit; + text-shadow: inherit; + border: inherit; +} + +/* Annotator Widget + + This applies to both the Viewer and the Editor +-------------------------------------------------------------------- */ + +.annotator-outer { + width: 0; + height: 0; +} + +.annotator-widget { + margin: 0; + padding: 0; + bottom: 15px; + left: -18px; + min-width: 265px; + background-color: rgba(251, 251, 251, 0.98); + border: 1px solid rgba(122, 122, 122, 0.6); + -webkit-border-radius: 5px; + -moz-border-radius: 5px; + border-radius: 5px; + -webkit-box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2); + -moz-box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2); + -o-box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2); + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2); +} + +.annotator-invert-x .annotator-widget { + left: auto; + right: -18px; +} + +.annotator-invert-y .annotator-widget { + bottom: auto; + top: 8px; +} + +.annotator-widget strong { + font-weight: bold; +} + +.annotator-widget .annotator-listing, +.annotator-widget .annotator-item { + padding: 0; + margin: 0; + list-style: none; +} + +.annotator-widget::after { + content: ""; + display: block; + width: 18px; + height: 10px; + background-position: 0 0; + position: absolute; + bottom: -10px; + left: 8px; +} + +.annotator-invert-x .annotator-widget::after { + left: auto; + right: 8px; +} + +.annotator-invert-y .annotator-widget::after { + background-position: 0 -15px; + bottom: auto; + top: -9px; +} + +.annotator-widget .annotator-item, +.annotator-editor .annotator-item input, +.annotator-editor .annotator-item textarea { + position: relative; + font-size: 12px; +} + +.annotator-viewer .annotator-item { + border-top: 2px solid rgba(122, 122, 122, 0.2); +} + +.annotator-widget .annotator-item:first-child { + border-top: none; +} + +.annotator-editor .annotator-item, +.annotator-viewer div { + border-top: 1px solid rgba(133, 133, 133, 0.11); +} + +/* Annotator Viewer +-------------------------------------------------------------------- */ + +.annotator-viewer div { + padding: 6px 6px; +} + +.annotator-viewer .annotator-item ol, +.annotator-viewer .annotator-item ul { + padding: 4px 16px; +} + +.annotator-viewer .annotator-item li { +} + +.annotator-viewer div:first-of-type, +.annotator-editor .annotator-item:first-child textarea { + padding-top: 12px; + padding-bottom: 12px; + color: rgb(60, 60, 60); + font-size: 13px; + font-style: italic; + line-height: 1.3; + border-top: none; +} + +.annotator-viewer .annotator-controls { + position: relative; + top: 5px; + right: 5px; + padding-left: 5px; + opacity: 0; + -webkit-transition: opacity 0.2s ease-in; + -moz-transition: opacity 0.2s ease-in; + -o-transition: opacity 0.2s ease-in; + transition: opacity 0.2s ease-in; + float: right; +} + +.annotator-viewer li:hover .annotator-controls, +.annotator-viewer li .annotator-controls.annotator-visible { + opacity: 1; +} + +.annotator-viewer .annotator-controls button, +.annotator-viewer .annotator-controls a { + cursor: pointer; + display: inline-block; + width: 13px; + height: 13px; + margin-left: 2px; + border: none; + opacity: 0.2; + text-indent: -900em; + background-color: transparent; + outline: none; +} + +.annotator-viewer .annotator-controls button:hover, +.annotator-viewer .annotator-controls button:focus, +.annotator-viewer .annotator-controls a:hover, +.annotator-viewer .annotator-controls a:focus { + opacity: 0.9; +} + +.annotator-viewer .annotator-controls button:active, +.annotator-viewer .annotator-controls a:active { + opacity: 1; +} + +.annotator-viewer .annotator-controls button[disabled] { + display: none; +} + +.annotator-viewer .annotator-controls .annotator-edit { + background-position: 0 -60px; +} + +.annotator-viewer .annotator-controls .annotator-delete { + background-position: 0 -75px; +} + +.annotator-viewer .annotator-controls .annotator-link { + background-position: 0 -270px; +} + +/* Annotator Editor +-------------------------------------------------------------------- */ + +.annotator-editor .annotator-item { + position: relative; +} + +.annotator-editor .annotator-item label { + top: 0; + display: inline; + cursor: pointer; + font-size: 12px; +} + +.annotator-editor .annotator-item input, +.annotator-editor .annotator-item textarea { + display: block; + min-width: 100%; + padding: 10px 8px; + border: none; + margin: 0; + color: rgb(60, 60, 60); + background: none; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + -o-box-sizing: border-box; + box-sizing: border-box; + resize: none; +} + +.annotator-editor .annotator-item textarea::-webkit-scrollbar { + height: 8px; + width: 8px; +} + +.annotator-editor .annotator-item textarea::-webkit-scrollbar-track-piece { + margin: 13px 0 3px; + background-color: #e5e5e5; + -webkit-border-radius: 4px; +} + +.annotator-editor .annotator-item textarea::-webkit-scrollbar-thumb:vertical { + height: 25px; + background-color: #ccc; + -webkit-border-radius: 4px; + -webkit-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1); +} + +.annotator-editor .annotator-item textarea::-webkit-scrollbar-thumb:horizontal { + width: 25px; + background-color: #ccc; + -webkit-border-radius: 4px; +} + +.annotator-editor .annotator-item:first-child textarea { + min-height: 5.5em; + -webkit-border-radius: 5px 5px 0 0; + -moz-border-radius: 5px 5px 0 0; + -o-border-radius: 5px 5px 0 0; + border-radius: 5px 5px 0 0; +} + +.annotator-editor .annotator-item input:focus, +.annotator-editor .annotator-item textarea:focus{ + background-color: rgb(243, 243, 243); + outline: none; +} + +.annotator-editor .annotator-item input[type=radio], +.annotator-editor .annotator-item input[type=checkbox] { + width: auto; + min-width: 0; + padding: 0; + display: inline; + margin: 0 4px 0 0; + cursor: pointer; +} + +.annotator-editor .annotator-checkbox { + padding: 8px 6px; +} + +.annotator-filter, +.annotator-filter .annotator-filter-navigation button, +.annotator-editor .annotator-controls { + text-align: right; + padding: 3px; + border-top: 1px solid rgb(212,212,212); + background-color: rgb(212, 212, 212); + background-image: -webkit-gradient( + linear, left top, left bottom, + from(rgb(245, 245, 245)), + color-stop(0.6, rgb(220, 220, 220)), + to(rgb(210, 210, 210)) + ); + background-image: -moz-linear-gradient( + -90deg, + rgb(245, 245, 245), + rgb(220, 220, 220) 60%, + rgb(210, 210, 210) + ); + background-image: -webkit-linear-gradient( + -90deg, + rgb(245, 245, 245), + rgb(220, 220, 220) 60%, + rgb(210, 210, 210) + ); + background-image: linear-gradient( + -90deg, + rgb(245, 245, 245), + rgb(220, 220, 220) 60%, + rgb(210, 210, 210) + ); + -webkit-box-shadow: + inset 1px 0 0 rgba(255, 255, 255, 0.7), + inset -1px 0 0 rgba(255, 255, 255, 0.7), + inset 0 1px 0 rgba(255, 255, 255, 0.7); + -moz-box-shadow: + inset 1px 0 0 rgba(255, 255, 255, 0.7), + inset -1px 0 0 rgba(255, 255, 255, 0.7), + inset 0 1px 0 rgba(255, 255, 255, 0.7); + -o-box-shadow: + inset 1px 0 0 rgba(255, 255, 255, 0.7), + inset -1px 0 0 rgba(255, 255, 255, 0.7), + inset 0 1px 0 rgba(255, 255, 255, 0.7); + box-shadow: + inset 1px 0 0 rgba(255, 255, 255, 0.7), + inset -1px 0 0 rgba(255, 255, 255, 0.7), + inset 0 1px 0 rgba(255, 255, 255, 0.7); + -webkit-border-radius: 0 0 5px 5px; + -moz-border-radius: 0 0 5px 5px; + -o-border-radius: 0 0 5px 5px; + border-radius: 0 0 5px 5px; +} + +.annotator-editor.annotator-invert-y .annotator-controls { + border-top: none; + border-bottom: 1px solid rgb(180, 180, 180); + -webkit-border-radius: 5px 5px 0 0; + -moz-border-radius: 5px 5px 0 0; + -o-border-radius: 5px 5px 0 0; + border-radius: 5px 5px 0 0; +} + +.annotator-editor a, +.annotator-filter .annotator-filter-property label { + position: relative; + display: inline-block; + padding: 0 6px 0 22px; + color: rgb(54, 54, 54); + text-shadow: 0 1px 0 rgba(255, 255, 255, 0.75); + text-decoration: none; + line-height: 24px; + font-size: 12px; + font-weight: bold; + border: 1px solid rgb(162, 162, 162); + background-color: rgb(212, 212, 212); + background-image: -webkit-gradient( + linear, left top, left bottom, + from(rgb(245, 245, 245)), + color-stop(0.5, rgb(210, 210, 210)), + color-stop(0.5, rgb(190, 190, 190)), + to(rgb(210, 210, 210)) + ); + background-image: -moz-linear-gradient( + -90deg, + rgb(245, 245, 245), + rgb(210, 210, 210) 50%, + rgb(190, 190, 190) 50%, + rgb(210, 210, 210) + ); + background-image: -webkit-linear-gradient( + -90deg, + rgb(245, 245, 245), + rgb(210, 210, 210) 50%, + rgb(190, 190, 190) 50%, + rgb(210, 210, 210) + ); + background-image: linear-gradient( + -90deg, + rgb(245, 245, 245), + rgb(210, 210, 210) 50%, + rgb(190, 190, 190) 50%, + rgb(210, 210, 210) + ); + -webkit-box-shadow: + inset 0 0 5px rgba(255, 255, 255, 0.2), + inset 0 0 1px rgba(255, 255, 255, 0.8); + -moz-box-shadow: + inset 0 0 5px rgba(255, 255, 255, 0.2), + inset 0 0 1px rgba(255, 255, 255, 0.8); + -o-box-shadow: + inset 0 0 5px rgba(255, 255, 255, 0.2), + inset 0 0 1px rgba(255, 255, 255, 0.8); + box-shadow: + inset 0 0 5px rgba(255, 255, 255, 0.2), + inset 0 0 1px rgba(255, 255, 255, 0.8); + -webkit-border-radius: 5px; + -moz-border-radius: 5px; + -o-border-radius: 5px; + border-radius: 5px; +} + +.annotator-editor a::after { + position: absolute; + top: 50%; + left: 5px; + display: block; + content: ""; + width: 15px; + height: 15px; + margin-top: -7px; + background-position: 0 -90px; +} + +.annotator-editor a:hover, +.annotator-editor a:focus, +.annotator-editor a.annotator-focus, +.annotator-filter .annotator-filter-active label, +.annotator-filter .annotator-filter-navigation button:hover { + outline: none; + border-color: rgb(67, 90, 160); + background-color: rgb(56, 101, 249); + background-image: -webkit-gradient( + linear, left top, left bottom, + from(rgb(118, 145, 251)), + color-stop(0.5, rgb(80, 117, 251)), + color-stop(0.5, rgb(56, 101, 249)), + to(rgb(54, 101, 250)) + ); + background-image: -moz-linear-gradient( + -90deg, + rgb(118, 145, 251), + rgb(80, 117, 251) 50%, + rgb(56, 101, 249) 50%, + rgb(54, 101, 250) + ); + background-image: -webkit-linear-gradient( + -90deg, + rgb(118, 145, 251), + rgb(80, 117, 251) 50%, + rgb(56, 101, 249) 50%, + rgb(54, 101, 250) + ); + background-image: linear-gradient( + -90deg, + rgb(118, 145, 251), + rgb(80, 117, 251) 50%, + rgb(56, 101, 249) 50%, + rgb(54, 101, 250) + ); + color: rgb(255, 255, 255); + text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.42); +} + +.annotator-editor a:hover::after, +.annotator-editor a:focus::after { + margin-top: -8px; + background-position: 0 -105px; +} + +.annotator-editor a:active, +.annotator-filter .annotator-filter-navigation button:active { + border-color: rgb(112, 12, 73); + background-color: rgb(209, 46, 142); + background-image: -webkit-gradient( + linear, left top, left bottom, + from(rgb(252, 124, 202)), + color-stop(0.5, rgb(232, 93, 178)), + color-stop(0.5, rgb(209, 46, 142)), + to(rgb(255, 0, 156)) + ); + background-image: -moz-linear-gradient( + -90deg, + rgb(252, 124, 202), + rgb(232, 93, 178) 50%, + rgb(209, 46, 142) 50%, + rgb(255, 0, 156) + ); + background-image: -webkit-linear-gradient( + -90deg, + rgb(252, 124, 202), + rgb(232, 93, 178) 50%, + rgb(209, 46, 142) 50%, + rgb(255, 0, 156) + ); + background-image: linear-gradient( + -90deg, + rgb(252, 124, 202), + rgb(232, 93, 178) 50%, + rgb(209, 46, 142) 50%, + rgb(255, 0, 156) + ); +} + +.annotator-editor a.annotator-save::after { + background-position: 0 -120px; +} + +.annotator-editor a.annotator-save:hover::after, +.annotator-editor a.annotator-save:focus::after, +.annotator-editor a.annotator-save.annotator-focus::after { + margin-top: -8px; + background-position: 0 -135px; +} + +.annotator-editor .annotator-widget::after { + background-position: 0 -30px; +} + +.annotator-editor.annotator-invert-y .annotator-widget .annotator-controls { + background-color: #f2f2f2; +} + +.annotator-editor.annotator-invert-y .annotator-widget::after { + background-position: 0 -45px; + height: 11px; +} + +.annotator-resize { + position: absolute; + top: 0; + right: 0; + width: 12px; + height: 12px; + background-position: 2px -150px; +} + +.annotator-invert-x .annotator-resize { + right: auto; + left: 0; + background-position: 0 -195px; +} + +.annotator-invert-y .annotator-resize { + top: auto; + bottom: 0; + background-position: 2px -165px; +} + +.annotator-invert-y.annotator-invert-x .annotator-resize { + background-position: 0 -180px; +} + +/* Annotator Notification +-------------------------------------------------------------------- */ + +.annotator-notice { + color: #fff; + position: absolute; + position: fixed; + top: -54px; + left: 0; + width: 100%; + font-size: 14px; + line-height: 50px; + text-align: center; + background: black; + background: rgba(0, 0, 0, 0.9); + border-bottom: 4px solid #d4d4d4; + -webkit-transition: top 0.4s ease-out; + -moz-transition: top 0.4s ease-out; + -o-transition: top 0.4s ease-out; + transition: top 0.4s ease-out; +} + +.ie6 .annotator-notice { + position: absolute; +} + +.annotator-notice-success { + border-color: #3665f9; +} + +.annotator-notice-error { + border-color: #ff7e00; +} + +.annotator-notice p { + margin: 0; +} + +.annotator-notice a { + color: #fff; +} + +.annotator-notice-show { + top: 0; +} + +/* Annotator Tags Plugin +-------------------------------------------------------------------- */ + +.annotator-tags { + margin-bottom: -2px; +} + +.annotator-tags .annotator-tag { + display: inline-block; + padding: 0 8px; + margin-bottom: 2px; + line-height: 1.6; + font-weight: bold; + background-color: rgb(230, 230, 230); + -webkit-border-radius: 8px; + -moz-border-radius: 8px; + -o-border-radius: 8px; + border-radius: 8px; +} + +/* Annotator Filter Plugin +-------------------------------------------------------------------- */ + +.annotator-filter { + position: fixed; + top: 0; + right: 0; + left: 0; + text-align: left; + line-height: 0; + border: none; + border-bottom: 1px solid #878787; + padding-left: 10px; + padding-right: 10px; + -webkit-border-radius: 0; + -moz-border-radius: 0; + -o-border-radius: 0; + border-radius: 0; + -webkit-box-shadow: + inset 0 -1px 0 rgba(255, 255, 255, 0.3); + -moz-box-shadow: + inset 0 -1px 0 rgba(255, 255, 255, 0.3); + -o-box-shadow: + inset 0 -1px 0 rgba(255, 255, 255, 0.3); + box-shadow: + inset 0 -1px 0 rgba(255, 255, 255, 0.3); +} + +.annotator-filter strong { + font-size: 12px; + font-weight: bold; + color: #3c3c3c; + text-shadow: 0 1px 0 rgba(255, 255, 255, 0.7); + position: relative; + top: -9px; +} + + +.annotator-filter .annotator-filter-property, +.annotator-filter .annotator-filter-navigation { + position: relative; + display: inline-block; + overflow: hidden; + line-height: 10px; + padding: 2px 0; + margin-right: 8px; +} + +.annotator-filter .annotator-filter-property label, +.annotator-filter .annotator-filter-navigation button { + text-align: left; + display: block; + float: left; + line-height: 20px; + -webkit-border-radius: 10px 0 0 10px; + -moz-border-radius: 10px 0 0 10px; + -o-border-radius: 10px 0 0 10px; + border-radius: 10px 0 0 10px; +} + +.annotator-filter .annotator-filter-property label { + padding-left: 8px; +} + +.annotator-filter .annotator-filter-property input { + display: block; + float: right; + -webkit-appearance: none; + background-color: #fff; + border: 1px solid #878787; + border-left: none; + padding: 2px 4px; + line-height: 16px; + min-height: 16px; + font-size: 12px; + width: 150px; + color: #333; + background-color: #f8f8f8; + -webkit-border-radius: 0 10px 10px 0; + -moz-border-radius: 0 10px 10px 0; + -o-border-radius: 0 10px 10px 0; + border-radius: 0 10px 10px 0; + -webkit-box-shadow: + inset 0 1px 1px rgba(0, 0, 0, 0.2); + -moz-box-shadow: + inset 0 1px 1px rgba(0, 0, 0, 0.2); + -o-box-shadow: + inset 0 1px 1px rgba(0, 0, 0, 0.2); + box-shadow: + inset 0 1px 1px rgba(0, 0, 0, 0.2); + +} + +.annotator-filter .annotator-filter-property input:focus { + outline: none; + background-color: #fff; +} + +.annotator-filter .annotator-filter-clear { + position: absolute; + right: 3px; + top: 6px; + border: none; + text-indent: -900em; + width: 15px; + height: 15px; + background-position: 0 -90px; + opacity: 0.4; +} + +.annotator-filter .annotator-filter-clear:hover, +.annotator-filter .annotator-filter-clear:focus { + opacity: 0.8; +} + +.annotator-filter .annotator-filter-clear:active { + opacity: 1; +} + +.annotator-filter .annotator-filter-navigation button { + border: 1px solid rgb(162, 162, 162); + padding: 0; + text-indent: -900px; + width: 20px; + min-height: 22px; + -webkit-box-shadow: + inset 0 0 5px rgba(255, 255, 255, 0.2), + inset 0 0 1px rgba(255, 255, 255, 0.8); + -moz-box-shadow: + inset 0 0 5px rgba(255, 255, 255, 0.2), + inset 0 0 1px rgba(255, 255, 255, 0.8); + -o-box-shadow: + inset 0 0 5px rgba(255, 255, 255, 0.2), + inset 0 0 1px rgba(255, 255, 255, 0.8); + box-shadow: + inset 0 0 5px rgba(255, 255, 255, 0.2), + inset 0 0 1px rgba(255, 255, 255, 0.8); +} + +.annotator-filter .annotator-filter-navigation button, +.annotator-filter .annotator-filter-navigation button:hover, +.annotator-filter .annotator-filter-navigation button:focus { + color: transparent; +} + +.annotator-filter .annotator-filter-navigation button::after { + position: absolute; + top: 8px; + left: 8px; + content: ""; + display: block; + width: 9px; + height: 9px; + background-position: 0 -210px; +} + +.annotator-filter .annotator-filter-navigation button:hover::after { + background-position: 0 -225px; +} + +.annotator-filter .annotator-filter-navigation .annotator-filter-next { + -webkit-border-radius: 0 10px 10px 0; + -moz-border-radius: 0 10px 10px 0; + -o-border-radius: 0 10px 10px 0; + border-radius: 0 10px 10px 0; + border-left: none; +} + +.annotator-filter .annotator-filter-navigation .annotator-filter-next::after { + left: auto; + right: 7px; + background-position: 0 -240px; +} + +.annotator-filter .annotator-filter-navigation .annotator-filter-next:hover::after { + background-position: 0 -255px; +} + +.annotator-hl-active { + background: rgba(255, 255, 10, 0.8); +} + +.annotator-hl-filtered { + background-color: transparent; +} + diff --git a/lms/static/css/vendor/annotator.min.css b/lms/static/css/vendor/annotator.min.css new file mode 100644 index 0000000000..f0e81eaff1 --- /dev/null +++ b/lms/static/css/vendor/annotator.min.css @@ -0,0 +1 @@ +.annotator-notice,.annotator-filter *,.annotator-widget *{font-family:"Helvetica Neue",Arial,Helvetica,sans-serif;font-weight:normal;text-align:left;margin:0;padding:0;background:0;-webkit-transition:none;-moz-transition:none;-o-transition:none;transition:none;-moz-box-shadow:none;-webkit-box-shadow:none;-o-box-shadow:none;box-shadow:none;color:#909090}.annotator-adder{background-image:url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJAAAAAwCAYAAAD+WvNWAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAA2ZpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMC1jMDYwIDYxLjEzNDc3NywgMjAxMC8wMi8xMi0xNzozMjowMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0ieG1wLmRpZDowMzgwMTE3NDA3MjA2ODExODRCQUU5RDY0RTkyQTJDNiIgeG1wTU06RG9jdW1lbnRJRD0ieG1wLmRpZDowOUY5RUFERDYwOEIxMUUxOTQ1RDkyQzU2OTNEMDZENCIgeG1wTU06SW5zdGFuY2VJRD0ieG1wLmlpZDowOUY5RUFEQzYwOEIxMUUxOTQ1RDkyQzU2OTNEMDZENCIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ1M1IE1hY2ludG9zaCI+IDx4bXBNTTpEZXJpdmVkRnJvbSBzdFJlZjppbnN0YW5jZUlEPSJ4bXAuaWlkOjA1ODAxMTc0MDcyMDY4MTE5MTA5OUIyNDhFRUQ1QkM4IiBzdFJlZjpkb2N1bWVudElEPSJ4bXAuZGlkOjAzODAxMTc0MDcyMDY4MTE4NEJBRTlENjRFOTJBMkM2Ii8+IDwvcmRmOkRlc2NyaXB0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');background-repeat:no-repeat}.annotator-resize,.annotator-widget::after,.annotator-editor a::after,.annotator-viewer .annotator-controls button,.annotator-viewer .annotator-controls a,.annotator-filter .annotator-filter-navigation button::after,.annotator-filter .annotator-filter-property .annotator-filter-clear{background-image:url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAEiCAYAAAD0w4JOAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMC1jMDYwIDYxLjEzNDc3NywgMjAxMC8wMi8xMi0xNzozMjowMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNSBNYWNpbnRvc2giIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6RDY0MTMzNTM2QUQzMTFFMUE2REJERDgwQTM3Njg5NTUiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6RDY0MTMzNTQ2QUQzMTFFMUE2REJERDgwQTM3Njg5NTUiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo2ODkwQjlFQzZBRDExMUUxQTZEQkREODBBMzc2ODk1NSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDpENjQxMzM1MjZBRDMxMUUxQTZEQkREODBBMzc2ODk1NSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PkijPpwAABBRSURBVHja7JsJVBRX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');background-repeat:no-repeat}.annotator-hl{background:rgba(255,255,10,0.3)}.annotator-hl-temporary{background:rgba(0,124,255,0.3)}.annotator-wrapper{position:relative}.annotator-adder,.annotator-outer,.annotator-notice{z-index:1020}.annotator-filter{z-index:1010}.annotator-adder,.annotator-outer,.annotator-widget,.annotator-notice{position:absolute;font-size:10px;line-height:1}.annotator-hide{display:none;visibility:hidden}.annotator-adder{margin-top:-48px;margin-left:-24px;width:48px;height:48px;background-position:left top}.annotator-adder:hover{background-position:center top}.annotator-adder:active{background-position:center right}.annotator-adder button{display:block;width:36px;height:41px;margin:0 auto;border:0;background:0;text-indent:-999em;cursor:pointer}.annotator-adder button:hover,.annotator-adder button:active{background-color:inherit;-webkit-box-shadow:inherit;-moz-box-shadow:inherit;box-shadow:inherit;text-shadow:inherit;border:inherit}.annotator-outer{width:0;height:0}.annotator-widget{margin:0;padding:0;bottom:15px;left:-18px;min-width:265px;background-color:rgba(251,251,251,0.98);border:1px solid rgba(122,122,122,0.6);-webkit-border-radius:5px;-moz-border-radius:5px;border-radius:5px;-webkit-box-shadow:0 5px 15px rgba(0,0,0,0.2);-moz-box-shadow:0 5px 15px rgba(0,0,0,0.2);-o-box-shadow:0 5px 15px rgba(0,0,0,0.2);box-shadow:0 5px 15px rgba(0,0,0,0.2)}.annotator-invert-x .annotator-widget{left:auto;right:-18px}.annotator-invert-y .annotator-widget{bottom:auto;top:8px}.annotator-widget strong{font-weight:bold}.annotator-widget .annotator-listing,.annotator-widget .annotator-item{padding:0;margin:0;list-style:none}.annotator-widget::after{content:"";display:block;width:18px;height:10px;background-position:0 0;position:absolute;bottom:-10px;left:8px}.annotator-invert-x .annotator-widget::after{left:auto;right:8px}.annotator-invert-y .annotator-widget::after{background-position:0 -15px;bottom:auto;top:-9px}.annotator-widget .annotator-item,.annotator-editor .annotator-item input,.annotator-editor .annotator-item textarea{position:relative;font-size:12px}.annotator-viewer .annotator-item{border-top:2px solid rgba(122,122,122,0.2)}.annotator-widget .annotator-item:first-child{border-top:0}.annotator-editor .annotator-item,.annotator-viewer div{border-top:1px solid rgba(133,133,133,0.11)}.annotator-viewer div{padding:6px 6px}.annotator-viewer .annotator-item ol,.annotator-viewer .annotator-item ul{padding:4px 16px}.annotator-viewer div:first-of-type,.annotator-editor .annotator-item:first-child textarea{padding-top:12px;padding-bottom:12px;color:#3c3c3c;font-size:13px;font-style:italic;line-height:1.3;border-top:0}.annotator-viewer .annotator-controls{position:relative;top:5px;right:5px;padding-left:5px;opacity:0;-webkit-transition:opacity .2s ease-in;-moz-transition:opacity .2s ease-in;-o-transition:opacity .2s ease-in;transition:opacity .2s ease-in;float:right}.annotator-viewer li:hover .annotator-controls,.annotator-viewer li .annotator-controls.annotator-visible{opacity:1}.annotator-viewer .annotator-controls button,.annotator-viewer .annotator-controls a{cursor:pointer;display:inline-block;width:13px;height:13px;margin-left:2px;border:0;opacity:.2;text-indent:-900em;background-color:transparent;outline:0}.annotator-viewer .annotator-controls button:hover,.annotator-viewer .annotator-controls button:focus,.annotator-viewer .annotator-controls a:hover,.annotator-viewer .annotator-controls a:focus{opacity:.9}.annotator-viewer .annotator-controls button:active,.annotator-viewer .annotator-controls a:active{opacity:1}.annotator-viewer .annotator-controls button[disabled]{display:none}.annotator-viewer .annotator-controls .annotator-edit{background-position:0 -60px}.annotator-viewer .annotator-controls .annotator-delete{background-position:0 -75px}.annotator-viewer .annotator-controls .annotator-link{background-position:0 -270px}.annotator-editor .annotator-item{position:relative}.annotator-editor .annotator-item label{top:0;display:inline;cursor:pointer;font-size:12px}.annotator-editor .annotator-item input,.annotator-editor .annotator-item textarea{display:block;min-width:100%;padding:10px 8px;border:0;margin:0;color:#3c3c3c;background:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;-o-box-sizing:border-box;box-sizing:border-box;resize:none}.annotator-editor .annotator-item textarea::-webkit-scrollbar{height:8px;width:8px}.annotator-editor .annotator-item textarea::-webkit-scrollbar-track-piece{margin:13px 0 3px;background-color:#e5e5e5;-webkit-border-radius:4px}.annotator-editor .annotator-item textarea::-webkit-scrollbar-thumb:vertical{height:25px;background-color:#ccc;-webkit-border-radius:4px;-webkit-box-shadow:0 1px 1px rgba(0,0,0,0.1)}.annotator-editor .annotator-item textarea::-webkit-scrollbar-thumb:horizontal{width:25px;background-color:#ccc;-webkit-border-radius:4px}.annotator-editor .annotator-item:first-child textarea{min-height:5.5em;-webkit-border-radius:5px 5px 0 0;-moz-border-radius:5px 5px 0 0;-o-border-radius:5px 5px 0 0;border-radius:5px 5px 0 0}.annotator-editor .annotator-item input:focus,.annotator-editor .annotator-item textarea:focus{background-color:#f3f3f3;outline:0}.annotator-editor .annotator-item input[type=radio],.annotator-editor .annotator-item input[type=checkbox]{width:auto;min-width:0;padding:0;display:inline;margin:0 4px 0 0;cursor:pointer}.annotator-editor .annotator-checkbox{padding:8px 6px}.annotator-filter,.annotator-filter .annotator-filter-navigation button,.annotator-editor .annotator-controls{text-align:right;padding:3px;border-top:1px solid #d4d4d4;background-color:#d4d4d4;background-image:-webkit-gradient(linear,left top,left bottom,from(#f5f5f5),color-stop(0.6,#dcdcdc),to(#d2d2d2));background-image:-moz-linear-gradient(-90deg,#f5f5f5,#dcdcdc 60%,#d2d2d2);background-image:-webkit-linear-gradient(-90deg,#f5f5f5,#dcdcdc 60%,#d2d2d2);background-image:linear-gradient(-90deg,#f5f5f5,#dcdcdc 60%,#d2d2d2);-webkit-box-shadow:inset 1px 0 0 rgba(255,255,255,0.7),inset -1px 0 0 rgba(255,255,255,0.7),inset 0 1px 0 rgba(255,255,255,0.7);-moz-box-shadow:inset 1px 0 0 rgba(255,255,255,0.7),inset -1px 0 0 rgba(255,255,255,0.7),inset 0 1px 0 rgba(255,255,255,0.7);-o-box-shadow:inset 1px 0 0 rgba(255,255,255,0.7),inset -1px 0 0 rgba(255,255,255,0.7),inset 0 1px 0 rgba(255,255,255,0.7);box-shadow:inset 1px 0 0 rgba(255,255,255,0.7),inset -1px 0 0 rgba(255,255,255,0.7),inset 0 1px 0 rgba(255,255,255,0.7);-webkit-border-radius:0 0 5px 5px;-moz-border-radius:0 0 5px 5px;-o-border-radius:0 0 5px 5px;border-radius:0 0 5px 5px}.annotator-editor.annotator-invert-y .annotator-controls{border-top:0;border-bottom:1px solid #b4b4b4;-webkit-border-radius:5px 5px 0 0;-moz-border-radius:5px 5px 0 0;-o-border-radius:5px 5px 0 0;border-radius:5px 5px 0 0}.annotator-editor a,.annotator-filter .annotator-filter-property label{position:relative;display:inline-block;padding:0 6px 0 22px;color:#363636;text-shadow:0 1px 0 rgba(255,255,255,0.75);text-decoration:none;line-height:24px;font-size:12px;font-weight:bold;border:1px solid #a2a2a2;background-color:#d4d4d4;background-image:-webkit-gradient(linear,left top,left bottom,from(#f5f5f5),color-stop(0.5,#d2d2d2),color-stop(0.5,#bebebe),to(#d2d2d2));background-image:-moz-linear-gradient(-90deg,#f5f5f5,#d2d2d2 50%,#bebebe 50%,#d2d2d2);background-image:-webkit-linear-gradient(-90deg,#f5f5f5,#d2d2d2 50%,#bebebe 50%,#d2d2d2);background-image:linear-gradient(-90deg,#f5f5f5,#d2d2d2 50%,#bebebe 50%,#d2d2d2);-webkit-box-shadow:inset 0 0 5px rgba(255,255,255,0.2),inset 0 0 1px rgba(255,255,255,0.8);-moz-box-shadow:inset 0 0 5px rgba(255,255,255,0.2),inset 0 0 1px rgba(255,255,255,0.8);-o-box-shadow:inset 0 0 5px rgba(255,255,255,0.2),inset 0 0 1px rgba(255,255,255,0.8);box-shadow:inset 0 0 5px rgba(255,255,255,0.2),inset 0 0 1px rgba(255,255,255,0.8);-webkit-border-radius:5px;-moz-border-radius:5px;-o-border-radius:5px;border-radius:5px}.annotator-editor a::after{position:absolute;top:50%;left:5px;display:block;content:"";width:15px;height:15px;margin-top:-7px;background-position:0 -90px}.annotator-editor a:hover,.annotator-editor a:focus,.annotator-editor a.annotator-focus,.annotator-filter .annotator-filter-active label,.annotator-filter .annotator-filter-navigation button:hover{outline:0;border-color:#435aa0;background-color:#3865f9;background-image:-webkit-gradient(linear,left top,left bottom,from(#7691fb),color-stop(0.5,#5075fb),color-stop(0.5,#3865f9),to(#3665fa));background-image:-moz-linear-gradient(-90deg,#7691fb,#5075fb 50%,#3865f9 50%,#3665fa);background-image:-webkit-linear-gradient(-90deg,#7691fb,#5075fb 50%,#3865f9 50%,#3665fa);background-image:linear-gradient(-90deg,#7691fb,#5075fb 50%,#3865f9 50%,#3665fa);color:#fff;text-shadow:0 -1px 0 rgba(0,0,0,0.42)}.annotator-editor a:hover::after,.annotator-editor a:focus::after{margin-top:-8px;background-position:0 -105px}.annotator-editor a:active,.annotator-filter .annotator-filter-navigation button:active{border-color:#700c49;background-color:#d12e8e;background-image:-webkit-gradient(linear,left top,left bottom,from(#fc7cca),color-stop(0.5,#e85db2),color-stop(0.5,#d12e8e),to(#ff009c));background-image:-moz-linear-gradient(-90deg,#fc7cca,#e85db2 50%,#d12e8e 50%,#ff009c);background-image:-webkit-linear-gradient(-90deg,#fc7cca,#e85db2 50%,#d12e8e 50%,#ff009c);background-image:linear-gradient(-90deg,#fc7cca,#e85db2 50%,#d12e8e 50%,#ff009c)}.annotator-editor a.annotator-save::after{background-position:0 -120px}.annotator-editor a.annotator-save:hover::after,.annotator-editor a.annotator-save:focus::after,.annotator-editor a.annotator-save.annotator-focus::after{margin-top:-8px;background-position:0 -135px}.annotator-editor .annotator-widget::after{background-position:0 -30px}.annotator-editor.annotator-invert-y .annotator-widget .annotator-controls{background-color:#f2f2f2}.annotator-editor.annotator-invert-y .annotator-widget::after{background-position:0 -45px;height:11px}.annotator-resize{position:absolute;top:0;right:0;width:12px;height:12px;background-position:2px -150px}.annotator-invert-x .annotator-resize{right:auto;left:0;background-position:0 -195px}.annotator-invert-y .annotator-resize{top:auto;bottom:0;background-position:2px -165px}.annotator-invert-y.annotator-invert-x .annotator-resize{background-position:0 -180px}.annotator-notice{color:#fff;position:absolute;position:fixed;top:-54px;left:0;width:100%;font-size:14px;line-height:50px;text-align:center;background:black;background:rgba(0,0,0,0.9);border-bottom:4px solid #d4d4d4;-webkit-transition:top .4s ease-out;-moz-transition:top .4s ease-out;-o-transition:top .4s ease-out;transition:top .4s ease-out}.ie6 .annotator-notice{position:absolute}.annotator-notice-success{border-color:#3665f9}.annotator-notice-error{border-color:#ff7e00}.annotator-notice p{margin:0}.annotator-notice a{color:#fff}.annotator-notice-show{top:0}.annotator-tags{margin-bottom:-2px}.annotator-tags .annotator-tag{display:inline-block;padding:0 8px;margin-bottom:2px;line-height:1.6;font-weight:bold;background-color:#e6e6e6;-webkit-border-radius:8px;-moz-border-radius:8px;-o-border-radius:8px;border-radius:8px}.annotator-filter{position:fixed;top:0;right:0;left:0;text-align:left;line-height:0;border:0;border-bottom:1px solid #878787;padding-left:10px;padding-right:10px;-webkit-border-radius:0;-moz-border-radius:0;-o-border-radius:0;border-radius:0;-webkit-box-shadow:inset 0 -1px 0 rgba(255,255,255,0.3);-moz-box-shadow:inset 0 -1px 0 rgba(255,255,255,0.3);-o-box-shadow:inset 0 -1px 0 rgba(255,255,255,0.3);box-shadow:inset 0 -1px 0 rgba(255,255,255,0.3)}.annotator-filter strong{font-size:12px;font-weight:bold;color:#3c3c3c;text-shadow:0 1px 0 rgba(255,255,255,0.7);position:relative;top:-9px}.annotator-filter .annotator-filter-property,.annotator-filter .annotator-filter-navigation{position:relative;display:inline-block;overflow:hidden;line-height:10px;padding:2px 0;margin-right:8px}.annotator-filter .annotator-filter-property label,.annotator-filter .annotator-filter-navigation button{text-align:left;display:block;float:left;line-height:20px;-webkit-border-radius:10px 0 0 10px;-moz-border-radius:10px 0 0 10px;-o-border-radius:10px 0 0 10px;border-radius:10px 0 0 10px}.annotator-filter .annotator-filter-property label{padding-left:8px}.annotator-filter .annotator-filter-property input{display:block;float:right;-webkit-appearance:none;background-color:#fff;border:1px solid #878787;border-left:none;padding:2px 4px;line-height:16px;min-height:16px;font-size:12px;width:150px;color:#333;background-color:#f8f8f8;-webkit-border-radius:0 10px 10px 0;-moz-border-radius:0 10px 10px 0;-o-border-radius:0 10px 10px 0;border-radius:0 10px 10px 0;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.2);-moz-box-shadow:inset 0 1px 1px rgba(0,0,0,0.2);-o-box-shadow:inset 0 1px 1px rgba(0,0,0,0.2);box-shadow:inset 0 1px 1px rgba(0,0,0,0.2)}.annotator-filter .annotator-filter-property input:focus{outline:0;background-color:#fff}.annotator-filter .annotator-filter-clear{position:absolute;right:3px;top:6px;border:0;text-indent:-900em;width:15px;height:15px;background-position:0 -90px;opacity:.4}.annotator-filter .annotator-filter-clear:hover,.annotator-filter .annotator-filter-clear:focus{opacity:.8}.annotator-filter .annotator-filter-clear:active{opacity:1}.annotator-filter .annotator-filter-navigation button{border:1px solid #a2a2a2;padding:0;text-indent:-900px;width:20px;min-height:22px;-webkit-box-shadow:inset 0 0 5px rgba(255,255,255,0.2),inset 0 0 1px rgba(255,255,255,0.8);-moz-box-shadow:inset 0 0 5px rgba(255,255,255,0.2),inset 0 0 1px rgba(255,255,255,0.8);-o-box-shadow:inset 0 0 5px rgba(255,255,255,0.2),inset 0 0 1px rgba(255,255,255,0.8);box-shadow:inset 0 0 5px rgba(255,255,255,0.2),inset 0 0 1px rgba(255,255,255,0.8)}.annotator-filter .annotator-filter-navigation button,.annotator-filter .annotator-filter-navigation button:hover,.annotator-filter .annotator-filter-navigation button:focus{color:transparent}.annotator-filter .annotator-filter-navigation button::after{position:absolute;top:8px;left:8px;content:"";display:block;width:9px;height:9px;background-position:0 -210px}.annotator-filter .annotator-filter-navigation button:hover::after{background-position:0 -225px}.annotator-filter .annotator-filter-navigation .annotator-filter-next{-webkit-border-radius:0 10px 10px 0;-moz-border-radius:0 10px 10px 0;-o-border-radius:0 10px 10px 0;border-radius:0 10px 10px 0;border-left:none}.annotator-filter .annotator-filter-navigation .annotator-filter-next::after{left:auto;right:7px;background-position:0 -240px}.annotator-filter .annotator-filter-navigation .annotator-filter-next:hover::after{background-position:0 -255px}.annotator-hl-active{background:rgba(255,255,10,0.8)}.annotator-hl-filtered{background-color:transparent} \ No newline at end of file diff --git a/lms/static/images/bg-banner-example.png b/lms/static/images/bg-banner-example.png new file mode 100644 index 0000000000..a52ffb6ef2 Binary files /dev/null and b/lms/static/images/bg-banner-example.png differ diff --git a/lms/static/images/bg-banner-login.png b/lms/static/images/bg-banner-login.png new file mode 100644 index 0000000000..4bbba21628 Binary files /dev/null and b/lms/static/images/bg-banner-login.png differ diff --git a/lms/static/images/bg-banner-register.png b/lms/static/images/bg-banner-register.png new file mode 100644 index 0000000000..f1fe626f05 Binary files /dev/null and b/lms/static/images/bg-banner-register.png differ diff --git a/lms/static/images/bg-footer-divider.jpg b/lms/static/images/bg-footer-divider.jpg new file mode 100644 index 0000000000..9d5e6fa6cd Binary files /dev/null and b/lms/static/images/bg-footer-divider.jpg differ diff --git a/lms/static/images/flagged.png b/lms/static/images/flagged.png new file mode 100644 index 0000000000..ad2b0dac55 Binary files /dev/null and b/lms/static/images/flagged.png differ diff --git a/lms/static/images/header-logo.png b/lms/static/images/header-logo.png index f1d2357e6b..df8cb13233 100644 Binary files a/lms/static/images/header-logo.png and b/lms/static/images/header-logo.png differ diff --git a/lms/static/images/notflagged.png b/lms/static/images/notflagged.png new file mode 100644 index 0000000000..fda47d5ab5 Binary files /dev/null and b/lms/static/images/notflagged.png differ diff --git a/lms/static/images/pinned.png b/lms/static/images/pinned.png index 76bb207fff..e70df7f9db 100644 Binary files a/lms/static/images/pinned.png and b/lms/static/images/pinned.png differ diff --git a/lms/static/images/press-kit/anant-agarwal_high-res.jpg b/lms/static/images/press-kit/anant-agarwal_high-res.jpg new file mode 100755 index 0000000000..b154ce99fb Binary files /dev/null and b/lms/static/images/press-kit/anant-agarwal_high-res.jpg differ diff --git a/lms/static/images/press-kit/anant-agarwal_high-res.jpg.REMOVED.git-id b/lms/static/images/press-kit/anant-agarwal_high-res.jpg.REMOVED.git-id deleted file mode 100644 index 414d117127..0000000000 --- a/lms/static/images/press-kit/anant-agarwal_high-res.jpg.REMOVED.git-id +++ /dev/null @@ -1 +0,0 @@ -b154ce99fb5c8d413ba769e8cc0df94ed674c3f4 \ No newline at end of file diff --git a/lms/static/images/press-kit/anant-tablet_high-res.jpg b/lms/static/images/press-kit/anant-tablet_high-res.jpg new file mode 100755 index 0000000000..2b8c58b098 Binary files /dev/null and b/lms/static/images/press-kit/anant-tablet_high-res.jpg differ diff --git a/lms/static/images/press-kit/anant-tablet_high-res.jpg.REMOVED.git-id b/lms/static/images/press-kit/anant-tablet_high-res.jpg.REMOVED.git-id deleted file mode 100644 index c3ba812427..0000000000 --- a/lms/static/images/press-kit/anant-tablet_high-res.jpg.REMOVED.git-id +++ /dev/null @@ -1 +0,0 @@ -2b8c58b098bdb17f9ddcbb2098f94c50fdcedf60 \ No newline at end of file diff --git a/lms/static/images/press-kit/edx-video-editing_high-res.jpg b/lms/static/images/press-kit/edx-video-editing_high-res.jpg new file mode 100755 index 0000000000..7d8b9879f7 Binary files /dev/null and b/lms/static/images/press-kit/edx-video-editing_high-res.jpg differ diff --git a/lms/static/images/press-kit/edx-video-editing_high-res.jpg.REMOVED.git-id b/lms/static/images/press-kit/edx-video-editing_high-res.jpg.REMOVED.git-id deleted file mode 100644 index 2b3ee55249..0000000000 --- a/lms/static/images/press-kit/edx-video-editing_high-res.jpg.REMOVED.git-id +++ /dev/null @@ -1 +0,0 @@ -7d8b9879f7e5b859910edba7249661eedd3fcf37 \ No newline at end of file diff --git a/lms/static/images/press-kit/piotr-mitros_high-res.jpg b/lms/static/images/press-kit/piotr-mitros_high-res.jpg new file mode 100755 index 0000000000..caf8b43337 Binary files /dev/null and b/lms/static/images/press-kit/piotr-mitros_high-res.jpg differ diff --git a/lms/static/images/press-kit/piotr-mitros_high-res.jpg.REMOVED.git-id b/lms/static/images/press-kit/piotr-mitros_high-res.jpg.REMOVED.git-id deleted file mode 100644 index 23fb583f92..0000000000 --- a/lms/static/images/press-kit/piotr-mitros_high-res.jpg.REMOVED.git-id +++ /dev/null @@ -1 +0,0 @@ -caf8b43337faa75cef5da5cd090010215a67b1bd \ No newline at end of file diff --git a/lms/static/images/press/cbsnews_178x138.jpg b/lms/static/images/press/cbsnews_178x138.jpg new file mode 100644 index 0000000000..c9fea9e31f Binary files /dev/null and b/lms/static/images/press/cbsnews_178x138.jpg differ diff --git a/lms/static/images/press/nytimes_240x180.png b/lms/static/images/press/nytimes_240x180.png new file mode 100644 index 0000000000..d34376a91f Binary files /dev/null and b/lms/static/images/press/nytimes_240x180.png differ diff --git a/lms/static/images/press/releases/dr-lewin-276_2400x1600.jpg b/lms/static/images/press/releases/dr-lewin-276_2400x1600.jpg new file mode 100644 index 0000000000..b4d043bb1c Binary files /dev/null and b/lms/static/images/press/releases/dr-lewin-276_2400x1600.jpg differ diff --git a/lms/static/images/press/releases/dr-lewin-276_2400x1600.jpg.REMOVED.git-id b/lms/static/images/press/releases/dr-lewin-276_2400x1600.jpg.REMOVED.git-id deleted file mode 100644 index 7166a5027c..0000000000 --- a/lms/static/images/press/releases/dr-lewin-276_2400x1600.jpg.REMOVED.git-id +++ /dev/null @@ -1 +0,0 @@ -b4d043bb1ca4a8815d4a388a2c9d96038211417b \ No newline at end of file diff --git a/lms/static/images/press/releases/mass-edx-gates-launch_3800x2184.jpg b/lms/static/images/press/releases/mass-edx-gates-launch_3800x2184.jpg new file mode 100644 index 0000000000..6718f0c6e8 Binary files /dev/null and b/lms/static/images/press/releases/mass-edx-gates-launch_3800x2184.jpg differ diff --git a/lms/static/images/press/releases/mass-edx-gates-launch_3800x2184.jpg.REMOVED.git-id b/lms/static/images/press/releases/mass-edx-gates-launch_3800x2184.jpg.REMOVED.git-id deleted file mode 100644 index d713443cfb..0000000000 --- a/lms/static/images/press/releases/mass-edx-gates-launch_3800x2184.jpg.REMOVED.git-id +++ /dev/null @@ -1 +0,0 @@ -6718f0c6e851376b5478baff94e1f1f4449bd938 \ No newline at end of file diff --git a/lms/static/images/press/releases/stanford-university-m.png b/lms/static/images/press/releases/stanford-university-m.png new file mode 100644 index 0000000000..bcde63022a Binary files /dev/null and b/lms/static/images/press/releases/stanford-university-m.png differ diff --git a/lms/static/images/press/wash_post_logo_178x138.jpg b/lms/static/images/press/wash_post_logo_178x138.jpg new file mode 100644 index 0000000000..bbc25b18b7 Binary files /dev/null and b/lms/static/images/press/wash_post_logo_178x138.jpg differ diff --git a/lms/static/images/resolvedflag.png b/lms/static/images/resolvedflag.png new file mode 100644 index 0000000000..8e318f786c Binary files /dev/null and b/lms/static/images/resolvedflag.png differ diff --git a/lms/static/images/social/ico-social-facebook.png b/lms/static/images/social/ico-social-facebook.png new file mode 100644 index 0000000000..3588e7f29a Binary files /dev/null and b/lms/static/images/social/ico-social-facebook.png differ diff --git a/lms/static/images/social/ico-social-google.png b/lms/static/images/social/ico-social-google.png new file mode 100644 index 0000000000..f5c39640df Binary files /dev/null and b/lms/static/images/social/ico-social-google.png differ diff --git a/lms/static/images/social/ico-social-meetup.png b/lms/static/images/social/ico-social-meetup.png new file mode 100644 index 0000000000..52a7f447d7 Binary files /dev/null and b/lms/static/images/social/ico-social-meetup.png differ diff --git a/lms/static/images/social/ico-social-twitter.png b/lms/static/images/social/ico-social-twitter.png new file mode 100644 index 0000000000..c812e7dd5c Binary files /dev/null and b/lms/static/images/social/ico-social-twitter.png differ diff --git a/lms/static/images/social/ico-social-youtube.png b/lms/static/images/social/ico-social-youtube.png new file mode 100644 index 0000000000..65f167f742 Binary files /dev/null and b/lms/static/images/social/ico-social-youtube.png differ diff --git a/lms/static/images/university/epfl/epfl-cover.jpg b/lms/static/images/university/epfl/epfl-cover.jpg index 42b188c925..e585966e7b 100644 Binary files a/lms/static/images/university/epfl/epfl-cover.jpg and b/lms/static/images/university/epfl/epfl-cover.jpg differ diff --git a/lms/static/images/unpinned.png b/lms/static/images/unpinned.png index 030198f7e8..a5f5f02894 100644 Binary files a/lms/static/images/unpinned.png and b/lms/static/images/unpinned.png differ diff --git a/lms/static/sass/.gitignore b/lms/static/sass/.gitignore index c8578e8cd3..b3a5267117 100644 --- a/lms/static/sass/.gitignore +++ b/lms/static/sass/.gitignore @@ -1,2 +1 @@ *.css -module diff --git a/lms/static/sass/_discussion.scss b/lms/static/sass/_discussion.scss index 8b7e30179d..e6d6d4b373 100644 --- a/lms/static/sass/_discussion.scss +++ b/lms/static/sass/_discussion.scss @@ -85,8 +85,8 @@ } @-webkit-keyframes fadeIn { - 0% { opacity: 0; } - 100% { opacity: 1; } + 0% { opacity: 0.0; } + 100% { opacity: 1.0; } } @@ -95,6 +95,7 @@ body.discussion { + .new-post-form-errors { display: none; background: $error-red; @@ -756,11 +757,11 @@ body.discussion { &.is-open { width:60%; .browse-topic-drop-btn span { - opacity: 1; + opacity: 1.0; } .browse-topic-drop-icon { - opacity: 0; + opacity: 0.0; } &.is-dropped { @@ -813,7 +814,7 @@ body.discussion { &::-webkit-input-placeholder, &:-moz-placeholder, &:-ms-input-placeholder { - opacity: 1; + opacity: 1.0; } } } @@ -843,7 +844,7 @@ body.discussion { line-height: 58px; color: #333; text-shadow: 0 1px 0 rgba(255, 255, 255, .8); - opacity: 0; + opacity: 0.0; @include transition(opacity .2s); } .drop-arrow { @@ -861,7 +862,7 @@ body.discussion { height: 16px; margin-left: -12px; background: url(../images/browse-icon.png) no-repeat; - opacity: 1; + opacity: 1.0; @include transition(none); } @@ -1003,7 +1004,7 @@ body.discussion { &::-webkit-input-placeholder, &:-moz-placeholder, &:-ms-input-placeholder { - opacity: 0; + opacity: 0.0; @include transition(opacity .2s); } @@ -1339,8 +1340,8 @@ body.discussion { .discussion-article { position: relative; padding: 40px; - min-height: 468px; - + min-height: 468px; + a { word-wrap: break-word; } @@ -1393,6 +1394,9 @@ body.discussion { background-position: 0 0; } } + + + } .discussion-post { @@ -2495,7 +2499,6 @@ body.discussion { @extend .discussion-module } - .group-visibility-label { font-size: 12px; color:#000; @@ -2507,21 +2510,47 @@ body.discussion { font-size: 12px; float:right; padding-right: 5px; - font-style: italic; + font-style: italic; + cursor:pointer; + margin-right: 10px; + opacity: 0.8; + + span { + cursor: pointer; + } + + &:hover { + @include transition(opacity .2s); + opacity: 1.0; + } } - -.notpinned .icon -{ - display: inline-block; + +.discussion-pin-inline { + font-size: 12px; + float:right; + font-style: italic; + position: relative; + right:-20px; + top:-13px; + margin-right:35px; + margin-top:13px; + opacity: 1.0; + } + +.notpinned .icon { + display: block; + float: left; + margin: 3px; width: 10px; height: 14px; padding-right: 3px; background: transparent url('../images/unpinned.png') no-repeat 0 0; } -.pinned .icon -{ - display: inline-block; +.pinned .icon { + display: block; + float: left; + margin: 3px; width: 10px; height: 14px; padding-right: 3px; @@ -2531,9 +2560,65 @@ body.discussion { .pinned span { color: #B82066; font-style: italic; + //cursor change is here since pins are read-only for inline discussions. + cursor: default; } .notpinned span { color: #888; font-style: italic; + //cursor change is here since pins are read-only for inline discussions. + cursor: default; +} + +.pinned-false +{ +display:none; +} + +.discussion-flag-abuse { + font-size: 12px; + float:right; + padding-right: 5px; + font-style: italic; + cursor:pointer; + opacity: 0.8; + + &:hover { + @include transition(opacity .2s); + opacity: 1.0; + } + + } + +.notflagged .icon +{ + display: block; + float: left; + margin: 3px; + width: 10px; + height: 14px; + padding-right: 3px; + background: transparent url('../images/notflagged.png') no-repeat 0 0; +} + +.flagged .icon +{ + display: block; + float: left; + margin: 3px; + width: 10px; + height: 14px; + padding-right: 3px; + background: transparent url('../images/flagged.png') no-repeat 0 0; +} + +.flagged span { + color: #B82066; + font-style: italic; +} + +.notflagged span { + color: #888; + font-style: italic; } \ No newline at end of file diff --git a/lms/static/sass/_shame.scss b/lms/static/sass/_shame.scss new file mode 100644 index 0000000000..d3cc0b9a80 --- /dev/null +++ b/lms/static/sass/_shame.scss @@ -0,0 +1,100 @@ +// edX LMS - shame +// shame file - used for any bad-form/orphaned scss that knowingly violate edX FED architecture/standards (see - http://csswizardry.com/2013/04/shame-css/) +// ==================== + +// marketing site - registration iframe band-aid (poor form enough to isolate out) +.view-partial-mktgregister { + background: transparent; + + // dimensions needed for course about page on marketing site + .wrapper-view { + overflow: hidden; + } + + // button elements - not a better place to put these, sadly + .btn { + @include box-sizing('border-box'); + display: block; + padding: $baseline/2; + text-transform: lowercase; + color: $white; + letter-spacing: 0.1rem; + cursor: pointer; + text-align: center; + border: none !important; + text-decoration: none; + text-shadow: none; + letter-spacing: 0.1rem; + font-size: 17px; + font-weight: 300; + box-shadow: 0 !important; + + strong { + font-weight: 400; + text-transform: none; + } + } + + .btn-primary { + @extend .btn; + @include linear-gradient($m-blue-s1 5%, $m-blue-d1 95%); + + // no hover state conventions to follow from marketing :/ + &:hover, &:active { + + } + } + + .btn-secondary { + @extend .btn; + @include linear-gradient($m-gray 5%, $m-gray-d1 95%); + + // no hover state conventions to follow from marketing :/ + &:hover, &:active { + + } + } + + .btn-tertiary { + @extend .btn; + background: $m-blue-l1; + color: $m-blue; + + // no hover state conventions to follow from marketing :/ + &:hover, &:active { + + } + } + + // nav list + .list-actions { + list-style: none; + margin: 0; + padding: 0; + + .item { + margin: 0; + } + } + + .action { + + // register or access courseware + &.action-register, &.access-courseware { + @extend .btn-primary; + } + + // already registered but course not started or registration is closed + &.is-registered, &.registration-closed { + @extend .btn-secondary; + pointer-events: none !important; + } + + // coming soon + &.coming-soon { + @extend .btn-tertiary; + pointer-events: none !important; + outline: none; + } + } +} diff --git a/lms/static/sass/application.scss b/lms/static/sass/application.scss.mako similarity index 59% rename from lms/static/sass/application.scss rename to lms/static/sass/application.scss.mako index 519118af84..c310347b6f 100644 --- a/lms/static/sass/application.scss +++ b/lms/static/sass/application.scss.mako @@ -19,6 +19,7 @@ @import 'multicourse/home'; @import 'multicourse/dashboard'; +@import 'multicourse/account'; @import 'multicourse/testcenter-register'; @import 'multicourse/courses'; @import 'multicourse/course_about'; @@ -33,3 +34,18 @@ @import 'discussion'; @import 'news'; + +@import 'shame'; + +## THEMING +## ------- +## Set up this file to import an edX theme library if the environment +## indicates that a theme should be used. The assumption is that the +## theme resides outside of this main edX repository, in a directory +## called themes//, with its base Sass file in +## themes//static/sass/_.scss. That one entry +## point can be used to @import in as many other things as needed. +% if env.get('THEME_NAME') is not None: + // import theme's Sass overrides + @import '${env.get('THEME_NAME')}' +% endif diff --git a/lms/static/sass/base/_animations.scss b/lms/static/sass/base/_animations.scss index 0c95e6b5af..4c4620ca27 100644 --- a/lms/static/sass/base/_animations.scss +++ b/lms/static/sass/base/_animations.scss @@ -9,12 +9,12 @@ @mixin home-header-pop-up-keyframes { 0% { - opacity: 0; + opacity: 0.0; top: 300px; //@include transform(scale(0.9)); } 45% { - opacity: 1; + opacity: 1.0; } 65% { top: -40px; @@ -43,19 +43,19 @@ @mixin title-appear-keyframes { 0% { - opacity: 0; + opacity: 0.0; top: 60px; @include transform(scale(0.9)); } 20% { - opacity: 1; + opacity: 1.0; } 27% { // this % of total-time should be ~ 1.25s top: 40px; @include transform(scale(1)); } 90% { // this % of total-time is when 2nd half of animation starts - opacity: 1; + opacity: 1.0; top: 40px; @include transform(scale(1)); } @@ -79,24 +79,24 @@ @mixin home-appear-keyframes { 0% { - opacity: 0; + opacity: 0.0; top: 60px; @include transform(scale(0.9)); } 20% { - opacity: 1; + opacity: 1.0; } 30% { // this % of total-time should be ~ 1.25s top: 40px; @include transform(scale(1)); } 80% { // this % of total-time is when 2nd half of animation starts - opacity: 1; + opacity: 1.0; top: 40px; @include transform(scale(1)); } 100% { - opacity: 0; + opacity: 0.0; top: 60px; @include transform(scale(0.7)); } @@ -117,10 +117,10 @@ @mixin edx-appear-keyframes { 0% { - opacity: 0; + opacity: 0.0; } 100% { - opacity: 1; + opacity: 1.0; } } @@ -231,7 +231,7 @@ opacity: 0.9; } 80% { - opacity: 1; + opacity: 1.0; } 100% { bottom: 0px; diff --git a/lms/static/sass/base/_base.scss b/lms/static/sass/base/_base.scss index ca56f542d6..6f43a02df7 100644 --- a/lms/static/sass/base/_base.scss +++ b/lms/static/sass/base/_base.scss @@ -1,5 +1,9 @@ +// html { +// overflow-y: scroll; +// } + html, body { - background: rgb(250,250,250); + background: $body-bg; font-family: $sans-serif; font-size: 1em; font-style: normal; @@ -57,20 +61,20 @@ p + p, ul + p, ol + p { p { a:link, a:visited { - color: $blue; + color: $link-color; font: normal 1em/1em $serif; text-decoration: none; @include transition(all, 0.1s, linear); &:hover { - color: $blue; + color: $link-color; text-decoration: underline; } } } a:link, a:visited { - color: $blue; + color: $link-color; font: normal 1em/1em $sans-serif; text-decoration: none; @include transition(all, 0.1s, linear); @@ -81,9 +85,10 @@ a:link, a:visited { } .content-wrapper { - background: rgb(255,255,255); - margin: 0 auto 0; width: flex-grid(12); + margin: 0 auto; + background: $content-wrapper-bg; + padding-bottom: ($baseline*2); } .container { @@ -92,6 +97,7 @@ a:link, a:visited { padding: 0px 30px; max-width: grid-width(12); min-width: 760px; + width: flex-grid(12); } span.edx { @@ -158,7 +164,7 @@ mark { display: none; padding: 10px; @include linear-gradient(top, rgba(0, 0, 0, .1), rgba(0, 0, 0, .0)); - background-color: $pink; + background-color: $site-status-color; box-shadow: 0 -1px 0 rgba(0, 0, 0, .3) inset; font-size: 14px; @@ -202,5 +208,66 @@ mark { } } +.sr { + @include text-sr(); +} +.help-tab { + @include transform(rotate(-90deg)); + @include transform-origin(0 0); + top: 50%; + left: 0; + position: fixed; + z-index: 99; + a:link, a:visited { + cursor: pointer; + border: 1px solid #ccc; + border-top-style: none; + @include border-radius(0px 0px 10px 10px); + background: transparentize(#fff, 0.25); + color: transparentize(#333, 0.25); + font-weight: bold; + text-decoration: none; + padding: 6px 22px 11px; + display: inline-block; + + &:hover { + color: #fff; + background: #1D9DD9; + } + } +} + +.help-buttons { + padding: 10px 50px; + + a:link, a:visited { + padding: 15px 0px; + text-align: center; + cursor: pointer; + background: #fff; + text-decoration: none; + display: block; + border: 1px solid #ccc; + + &#feedback_link_problem { + border-bottom-style: none; + @include border-radius(10px 10px 0px 0px); + } + + &#feedback_link_question { + border-top-style: none; + @include border-radius(0px 0px 10px 10px); + } + + &:hover { + color: #fff; + background: #1D9DD9; + } + } +} + +#feedback_form textarea[name="details"] { + height: 150px; +} diff --git a/lms/static/sass/base/_extends.scss b/lms/static/sass/base/_extends.scss index 2998e25dca..d244eff55f 100644 --- a/lms/static/sass/base/_extends.scss +++ b/lms/static/sass/base/_extends.scss @@ -1,39 +1,30 @@ .faded-hr-divider { - @include background-image(linear-gradient(180deg, rgba(200,200,200, 0) 0%, - rgba(200,200,200, 1) 50%, - rgba(200,200,200, 0))); + @include background-image($faded-hr-image-1); height: 1px; width: 100%; } .faded-hr-divider-medium { - @include background-image(linear-gradient(180deg, rgba(240,240,240, 0) 0%, - rgba(240,240,240, 1) 50%, - rgba(240,240,240, 0))); + @include background-image($faded-hr-image-4); height: 1px; width: 100%; } .faded-hr-divider-light { - @include background-image(linear-gradient(180deg, rgba(255,255,255, 0) 0%, - rgba(255,255,255, 0.8) 50%, - rgba(255,255,255, 0))); + @include background-image($faded-hr-image-5); height: 1px; width: 100%; } .faded-vertical-divider { - @include background-image(linear-gradient(90deg, rgba(200,200,200, 0) 0%, - rgba(200,200,200, 1) 50%, - rgba(200,200,200, 0))); + @include background-image($faded-hr-image-1); height: 100%; width: 1px; } .faded-vertical-divider-light { - @include background-image(linear-gradient(90deg, rgba(255,255,255, 0) 0%, - rgba(255,255,255, 0.6) 50%, - rgba(255,255,255, 0))); + @include background-image($faded-hr-image-6); + background: transparent; height: 100%; width: 1px; } @@ -66,14 +57,12 @@ } .fade-right-hr-divider { - @include background-image(linear-gradient(180deg, rgba(200,200,200, 0) 0%, - rgba(200,200,200, 1))); + @include background-image($faded-hr-image-2); border: none; } .fade-left-hr-divider { - @include background-image(linear-gradient(180deg, rgba(200,200,200, 1) 0%, - rgba(200,200,200, 0))); + @include background-image($faded-hr-image-3); border: none; } diff --git a/lms/static/sass/base/_mixins.scss b/lms/static/sass/base/_mixins.scss index 58a92d1ee6..97703e8f0f 100644 --- a/lms/static/sass/base/_mixins.scss +++ b/lms/static/sass/base/_mixins.scss @@ -7,10 +7,23 @@ @return $body-line-height * $amount; } -@mixin hide-text(){ - text-indent: -9999px; +// image-replacement hidden text +@mixin text-hide() { + text-indent: 100%; + white-space: nowrap; overflow: hidden; - display: block; +} + +// hidden elems - screenreaders +@mixin text-sr() { + border: 0; + clip: rect(0 0 0 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + width: 1px; } @mixin vertically-and-horizontally-centered ( $height, $width ) { @@ -22,3 +35,10 @@ position: absolute; top: 150px; } + +// sunsetted, but still used mixins +@mixin hide-text(){ + text-indent: -9999px; + overflow: hidden; + display: block; +} diff --git a/lms/static/sass/base/_variables.scss b/lms/static/sass/base/_variables.scss index 4d27798649..6bd593c28c 100644 --- a/lms/static/sass/base/_variables.scss +++ b/lms/static/sass/base/_variables.scss @@ -1,3 +1,5 @@ +$baseline: 20px; + $gw-column: 80px; $gw-gutter: 20px; @@ -8,9 +10,9 @@ $fg-max-width: 1400px; $fg-min-width: 810px; $sans-serif: 'Open Sans', $verdana; +$monospace: Monaco, 'Bitstream Vera Sans Mono', 'Lucida Console', monospace; $body-font-family: $sans-serif; $serif: $georgia; -$monospace: Monaco, 'Bitstream Vera Sans Mono', 'Lucida Console', monospace; $body-font-size: em(14); $body-line-height: golden-ratio(.875em, 1); @@ -18,17 +20,106 @@ $base-font-color: rgb(60,60,60); $baseFontColor: rgb(60,60,60); $base-font-color: rgb(60,60,60); $lighter-base-font-color: rgb(100,100,100); +$very-light-text: #fff; +$white: rgb(255,255,255); +$black: rgb(0,0,0); $blue: rgb(29,157,217); $pink: rgb(182,37,104); $yellow: rgb(255, 252, 221); +$red: rgb(178, 6, 16); $error-red: rgb(253, 87, 87); -$border-color: #C8C8C8; -$sidebar-color: #f6f6f6; -$outer-border-color: #aaa; +$light-gray: rgb(221, 221, 221); +$dark-gray: rgb(51, 51, 51); +$border-color: rgb(200, 200, 200); +$sidebar-color: rgb(246, 246, 246); +$outer-border-color: rgb(170, 170, 170); // old variables $light-gray: #ddd; $dark-gray: #333; + +// edx.org-related +$m-gray-l1: rgb(203,203,203); +$m-gray-l2: rgb(246,246,246); +$m-gray: rgb(153,153,153); +$m-gray-d1: rgb(102,102,102); +$m-gray-d2: rgb(51,51,51); +$m-gray-a1: rgb(80,80,80); +$m-blue: rgb(85, 151, 221); +$m-blue-l1: rgb(230,245,252); +$m-blue-d1: shade($m-blue,15%); +$m-blue-s1: saturate($m-blue,15%); +$m-pink: rgb(204,51,102); + +$m-base-font-size: em(15); + + +$base-font-color: rgb(60,60,60); +$baseFontColor: rgb(60,60,60); +$lighter-base-font-color: rgb(100,100,100); $text-color: $dark-gray; +$body-bg: rgb(250,250,250); +$header-image: linear-gradient(-90deg, rgba(255,255,255, 1), rgba(230,230,230, 0.9)); +$header-bg: transparent; +$courseware-header-image: linear-gradient(top, #fff, #eee); +$courseware-header-bg: transparent; +$footer-bg: transparent; +$courseware-footer-border: none; +$courseware-footer-shadow: none; +$courseware-footer-margin: 0px; + +$button-bg-image: linear-gradient(#fff 0%, rgb(250,250,250) 50%, rgb(237,237,237) 50%, rgb(220,220,220) 100%); +$button-bg-color: transparent; +$button-bg-hover-color: #fff; + +$faded-hr-image-1: linear-gradient(180deg, rgba(200,200,200, 0) 0%, rgba(200,200,200, 1) 50%, rgba(200,200,200, 0)); +$faded-hr-image-2: linear-gradient(180deg, rgba(200,200,200, 0) 0%, rgba(200,200,200, 1)); +$faded-hr-image-3: linear-gradient(180deg, rgba(200,200,200, 1) 0%, rgba(200,200,200, 0)); +$faded-hr-image-4: linear-gradient(180deg, rgba(240,240,240, 0) 0%, rgba(240,240,240, 1) 50%, rgba(240,240,240, 0)); +$faded-hr-image-5: linear-gradient(180deg, rgba(255,255,255, 0) 0%, rgba(255,255,255, 0.8) 50%, rgba(255,255,255, 0)); +$faded-hr-image-6: linear-gradient(90deg, rgba(255,255,255, 0) 0%, rgba(255,255,255, 0.6) 50%, rgba(255,255,255, 0)); + +$dashboard-profile-header-image: linear-gradient(-90deg, rgb(255,255,255), rgb(245,245,245)); +$dashboard-profile-header-color: transparent; +$dashboard-profile-color: rgb(252,252,252); +$dot-color: $light-gray; + +$content-wrapper-bg: rgb(255,255,255); +$course-bg-color: #d6d6d6; +$course-bg-image: url(../images/bg-texture.png); + +$course-profile-bg: rgb(245,245,245); +$course-header-bg: rgba(255,255,255, 0.93); + +$border-color-1: rgb(190,190,190); +$border-color-2: rgb(200,200,200); +$border-color-3: rgb(100,100,100); +$border-color-4: rgb(252,252,252); + +$link-color: $blue; +$link-hover: $pink; +$selection-color-1: $pink; +$selection-color-2: #444; +$site-status-color: $pink; + +$button-color: $blue; +$button-archive-color: #eee; + +$shadow-color: $blue; + +$sidebar-chapter-bg-top: rgba(255, 255, 255, .6); +$sidebar-chapter-bg-bottom: rgba(255, 255, 255, 0); +$sidebar-chapter-bg: #eee; +$sidebar-active-image: linear-gradient(top, #e6e6e6, #d6d6d6); + +$form-bg-color: #fff; +$modal-bg-color: rgb(245,245,245); + +//----------------- +// CSS BG Images +//----------------- +$homepage-bg-image: '../images/homepage-bg.jpg'; + +$video-thumb-url: '../images/courses/video-thumb.jpg'; \ No newline at end of file diff --git a/lms/static/sass/bourbon b/lms/static/sass/bourbon deleted file mode 120000 index 6f53a8b404..0000000000 --- a/lms/static/sass/bourbon +++ /dev/null @@ -1 +0,0 @@ -../../../common/static/sass/bourbon/ \ No newline at end of file diff --git a/lms/static/sass/course.scss b/lms/static/sass/course.scss index 60eda5a5c8..e57865fa9d 100644 --- a/lms/static/sass/course.scss +++ b/lms/static/sass/course.scss @@ -15,7 +15,7 @@ @import 'course/base/mixins'; @import 'course/base/base'; @import 'course/base/extends'; -@import 'module/module-styles.scss'; +@import 'xmodule/modules/css/module-styles.scss'; // courseware @import 'course/courseware/courseware'; diff --git a/lms/static/sass/course/_info.scss b/lms/static/sass/course/_info.scss index bfd90505cf..741a7f9a22 100644 --- a/lms/static/sass/course/_info.scss +++ b/lms/static/sass/course/_info.scss @@ -117,7 +117,7 @@ div.info-wrapper { @include transition(all .2s); h4 { - color: $blue; + color: $link-color; font-size: 1em; font-weight: normal; padding-left: 30px; diff --git a/lms/static/sass/course/_textbook.scss b/lms/static/sass/course/_textbook.scss index 1d72ae3199..83aca09ab6 100644 --- a/lms/static/sass/course/_textbook.scss +++ b/lms/static/sass/course/_textbook.scss @@ -38,7 +38,7 @@ div.book-wrapper { line-height: 2.1em; text-align: right; color: #9a9a9a; - opacity: 0; + opacity: 0.0; @include transition(opacity .15s); } @@ -55,7 +55,7 @@ div.book-wrapper { background-color: transparent; .page-number { - opacity: 1; + opacity: 1.0; } } } @@ -119,7 +119,7 @@ div.book-wrapper { @include box-sizing(border-box); display: table; height: 100%; - opacity: 0; + opacity: 0.0; filter: alpha(opacity=0); text-indent: -9999px; @include transition; @@ -127,7 +127,7 @@ div.book-wrapper { width: 100%; &:hover { - opacity: 1; + opacity: 1.0; filter: alpha(opacity=100); } } diff --git a/lms/static/sass/course/base/_base.scss b/lms/static/sass/course/base/_base.scss index 6183c8a675..584412ca22 100644 --- a/lms/static/sass/course/base/_base.scss +++ b/lms/static/sass/course/base/_base.scss @@ -1,7 +1,8 @@ body { min-width: 980px; min-height: 100%; - background: url(../images/bg-texture.png) #d6d6d6; + background-image: $course-bg-image; + background-color: $course-bg-color; } body, h1, h2, h3, h4, h5, h6, p, p a:link, p a:visited, a, label { @@ -34,7 +35,7 @@ a { width: 100%; border-radius: 3px; border: 1px solid $outer-border-color; - background: #fff; + background: $body-bg; @include box-shadow(0 1px 2px rgba(0, 0, 0, 0.05)); } } @@ -49,8 +50,8 @@ textarea, input[type="text"], input[type="email"], input[type="password"] { - background: rgb(250,250,250); - border: 1px solid rgb(200,200,200); + background: $body-bg; + border: 1px solid $border-color-2; @include border-radius(0); @include box-shadow(0 1px 0 0 rgba(255,255,255, 0.6), inset 0 0 3px 0 rgba(0,0,0, 0.1)); @include box-sizing(border-box); @@ -65,7 +66,7 @@ input[type="password"] { } &:focus { - border-color: lighten($blue, 20%); + border-color: lighten($link-color, 20%); @include box-shadow(0 0 6px 0 rgba($blue, 0.4), inset 0 0 4px 0 rgba(0,0,0, 0.15)); outline: none; } @@ -94,7 +95,7 @@ img { } ::selection, ::-moz-selection, ::-webkit-selection { - background: #444; + background: $selection-color-2; color: #fff; } @@ -143,7 +144,7 @@ img { max-width: 350px; padding: 15px 20px 17px; border-radius: 3px; - border: 1px solid #333; + border: 1px solid $border-color-3; background: -webkit-linear-gradient(top, rgba(255, 255, 255, .1), rgba(255, 255, 255, 0)) rgba(30, 30, 30, .92); box-shadow: 0 1px 3px rgba(0, 0, 0, .3), 0 1px 0 rgba(255, 255, 255, .1) inset; font-size: 13px; diff --git a/lms/static/sass/course/base/_extends.scss b/lms/static/sass/course/base/_extends.scss index bcb93a3645..a94a9511fe 100644 --- a/lms/static/sass/course/base/_extends.scss +++ b/lms/static/sass/course/base/_extends.scss @@ -1,5 +1,5 @@ h1.top-header { - border-bottom: 1px solid #e3e3e3; + border-bottom: 1px solid $border-color-2; text-align: left; font-size: em(24); font-weight: 100; diff --git a/lms/static/sass/course/courseware/_sidebar.scss b/lms/static/sass/course/courseware/_sidebar.scss index 81b497d4f9..24bda451a7 100644 --- a/lms/static/sass/course/courseware/_sidebar.scss +++ b/lms/static/sass/course/courseware/_sidebar.scss @@ -2,7 +2,7 @@ section.course-index { @extend .sidebar; @extend .tran; @include border-radius(3px 0 0 3px); - border-right: 1px solid #ddd; + border-right: 1px solid $border-color-2; #open_close_accordion { display: none; @@ -61,7 +61,7 @@ section.course-index { span.ui-icon { left: 0; background-image: url("/static/images/ui-icons_222222_256x240.png"); - opacity: .3; + opacity: 0.3; } } } @@ -70,8 +70,8 @@ section.course-index { width: 100% !important; @include box-sizing(border-box); padding: 11px 14px; - @include linear-gradient(top, rgba(255, 255, 255, .6), rgba(255, 255, 255, 0)); - background-color: #eee; + @include linear-gradient(top, $sidebar-chapter-bg-top, $sidebar-chapter-bg-bottom); + background-color: $sidebar-chapter-bg; @include box-shadow(0 1px 0 #fff inset, 0 -1px 0 rgba(0, 0, 0, .1) inset); @include transition(background-color .1s); @@ -146,7 +146,7 @@ section.course-index { @include box-shadow(inset 0 1px 14px 0 rgba(0,0,0, 0.1)); &:after { - opacity: 1; + opacity: 1.0; right: 15px; } } @@ -169,12 +169,12 @@ section.course-index { } > a { - border: 1px solid #bbb; + border: 1px solid $border-color-1; @include box-shadow(0 1px 0 rgba(255, 255, 255, .35) inset); - @include linear-gradient(top, #e6e6e6, #d6d6d6); + background: $sidebar-active-image; &:after { - opacity: 1; + opacity: 1.0; right: 15px; } diff --git a/lms/static/sass/course/layout/_calculator.scss b/lms/static/sass/course/layout/_calculator.scss index 2819546f9f..c0a8764a8c 100644 --- a/lms/static/sass/course/layout/_calculator.scss +++ b/lms/static/sass/course/layout/_calculator.scss @@ -27,7 +27,7 @@ div.calc-main { width: 16px; &:hover { - opacity: .8; + opacity: 0.8; } &.closed { @@ -136,7 +136,7 @@ div.calc-main { &.shown { display: block; - opacity: 1; + opacity: 1.0; } dt { diff --git a/lms/static/sass/course/layout/_courseware_header.scss b/lms/static/sass/course/layout/_courseware_header.scss index b5c93f8e14..4d8f000668 100644 --- a/lms/static/sass/course/layout/_courseware_header.scss +++ b/lms/static/sass/course/layout/_courseware_header.scss @@ -61,10 +61,10 @@ nav.course-material { } header.global.slim { - border-bottom: 1px solid $outer-border-color; @include box-shadow(0 1px 2px rgba(0, 0, 0, .1)); height: 50px; - @include linear-gradient(top, #fff, #eee); + border-bottom: 1px solid $outer-border-color; + background: $white; .guest .secondary { margin-right: 0; @@ -75,9 +75,9 @@ header.global.slim { &#login { display: block; - @include background-image(linear-gradient(-90deg, lighten($blue, 8%), lighten($blue, 5%) 50%, $blue 50%, darken($blue, 10%) 100%)); + @include background-image(linear-gradient(-90deg, lighten($link-color, 8%), lighten($link-color, 5%) 50%, $link-color 50%, darken($link-color, 10%) 100%)); border: 1px solid transparent; - border-color: darken($blue, 10%); + border-color: darken($link-color, 10%); @include border-radius(3px); @include box-sizing(border-box); @include box-shadow(0 1px 0 0 rgba(255,255,255, 0.6)); @@ -97,7 +97,7 @@ header.global.slim { vertical-align: middle; &:hover, &.active { - @include background-image(linear-gradient(-90deg, $blue, $blue 50%, $blue 50%, $blue 100%)); + @include background-image(linear-gradient(-90deg, $link-color, $link-color 50%, $link-color 50%, $link-color 100%)); } } } @@ -111,7 +111,7 @@ header.global.slim { margin-right: 20px; padding-right: 20px; - &::before { + &:before { @extend .faded-vertical-divider; content: ""; display: block; @@ -122,7 +122,7 @@ header.global.slim { width: 1px; } - &::after { + &:after { @extend .faded-vertical-divider-light; content: ""; display: block; @@ -134,7 +134,7 @@ header.global.slim { } } - .find-courses-button { + .nav-global { display: none; } diff --git a/lms/static/sass/course/layout/_footer.scss b/lms/static/sass/course/layout/_footer.scss index 7abf35a819..699846e781 100644 --- a/lms/static/sass/course/layout/_footer.scss +++ b/lms/static/sass/course/layout/_footer.scss @@ -1,4 +1,5 @@ footer { - border: none; - box-shadow: none; + border: $courseware-footer-border; + box-shadow: $courseware-footer-shadow; + margin-top: $courseware-footer-margin; } \ No newline at end of file diff --git a/lms/static/sass/course/wiki/_wiki.scss b/lms/static/sass/course/wiki/_wiki.scss index 1bc38abd9a..d064b6d345 100644 --- a/lms/static/sass/course/wiki/_wiki.scss +++ b/lms/static/sass/course/wiki/_wiki.scss @@ -113,7 +113,7 @@ section.wiki { } &:focus { - border-color: $blue; + border-color: $link-color; } } } @@ -276,7 +276,7 @@ section.wiki { li { &.active { a { - color: $blue; + color: $link-color; .icon-view, .icon-home { diff --git a/lms/static/sass/ie.scss b/lms/static/sass/ie.scss index 4b0f5aa3c0..e03b711bae 100644 --- a/lms/static/sass/ie.scss +++ b/lms/static/sass/ie.scss @@ -26,7 +26,7 @@ header.global { } h2 { - opacity: 1; + opacity: 1.0; } } @@ -51,7 +51,7 @@ header.global { text-decoration: none; &::before { - opacity: 1; + opacity: 1.0; } .name { diff --git a/lms/static/sass/multicourse/_account.scss b/lms/static/sass/multicourse/_account.scss new file mode 100644 index 0000000000..eab8cbe66b --- /dev/null +++ b/lms/static/sass/multicourse/_account.scss @@ -0,0 +1,620 @@ +// plus on button +// border radius on inputs + +// Account-Centric (Login/Register) +// ===== + +// page-level +.view-register, .view-login, .view-passwordreset { + background: $white; + + + + // edx.org - marketing typography + .heading-1, .heading-2, .heading-3, .heading-4, .heading-5, .body-text-emphasized, .body-text, .button-primary, .button-secondary { + display: block; + font-family: $sans-serif; + line-height: lh(1); + } + + .heading-2 { + font-size: 25px; + margin: 0 0 $baseline 0; + font-weight: 300; + text-transform: uppercase; + color: $m-blue; + } + + .heading-3 { + font-size: 21px; + margin: 0 0 $baseline 0; + font-weight: 300; + color: $m-gray-d2; + } + + .heading-4 { + font-size: 14px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0 !important; + color: $m-blue-s1; + } + + .heading-5 { + } + + // specific examples - body + .body-text-emphasized { + font-size: 18px; + margin: 0 0 $baseline 0; + font-weight: 300; + color: $m-gray-a1; + font-family: 'Open Sans', sans-serif; + line-height: lh(1.1); + } + + .body-text { + font-size: 15px; + margin: 0 0 $baseline 0; + color: $m-gray-a1; + line-height: lh(1); + } + + // specific examples - buttons + .button-primary { + @include border-radius(0); + @include linear-gradient($m-blue-s1 5%, $m-blue-d1 95%); + display: inline-block; + padding: $baseline/2 $baseline*2.5; + text-transform: lowercase; + color: $white; + letter-spacing: 0.1rem; + font-weight: 500; + cursor: pointer; + text-align: center; + border: none !important; + text-shadow: none; + letter-spacing: 0; + font-size: 16px; + box-shadow: none !important; + } + + .button-secondary { + @include linear-gradient($m-gray 5%, $m-gray-d1 95%); + display: inline-block; + padding: $baseline/2 $baseline*2.5; + text-transform: lowercase; + color: $white; + letter-spacing: 0.1rem; + font-weight: 600; + cursor: pointer; + text-align: center; + border: none !important; + text-shadow: none; + letter-spacing: 0; + font-size: 16px; + box-shadow: 0 !important; + } + + // layout + .content-wrapper { + background: $m-gray-l2; + padding-bottom: 0; + } + + .container, .introduction { + @include box-sizing(border-box); + @include clearfix; + margin: 0 auto; + width: 960px; + background: $white; + } + + .container { + padding: $baseline $baseline ($baseline*2) $baseline; + } + + .introduction { + padding: ($baseline*2) $baseline 0 $baseline; + + header h1 { + @extend .heading-2; + margin-bottom: $baseline; + padding-bottom: $baseline; + text-align: left; + } + } +} + +// shared +.login, .register, .passwordreset, #forgot-password-modal #password-reset { + + // reset - horrible, but necessary + p, ol, ul, h1, h2, h3, h4, h5, h6, label, input, textarea { + @extend .body-text; + } + + h1, h2, h3, h4, h5, h6 { + letter-spacing: 0; + } + + a { + @include transition(color 0.15s ease-in-out, border 0.15s ease-in-out); + + &:link, &:visited, &:hover, &:active { + color: $m-blue; + text-decoration: none !important; + font-family: $sans-serif; + } + + &:hover, &:active { + border-bottom: 1px dotted $m-blue-s1; + color: $m-blue-s1; + } + } + + strong { + font-weight: 600; + } + + // basic layout + .content, aside { + @include box-sizing(border-box); + margin: $baseline 0 0 0; + } + + .content { + margin-right: ($baseline*2); + width: 600px; + float: left; + } + + aside { + width: 280px; + float: left; + + p, ol, ul { + font-size: 14px !important; + } + } + + // content + .content { + } + + // aside + aside { + + .cta { + margin: 0 0 ($baseline*2) 0; + + &:last-child { + margin-bottom: 0; + } + + h3 { + @extend .heading-4; + margin: 0 0 ($baseline/4) 0; + } + } + } + + // forms + form { + + .instructions { + @extend .body-text-emphasized; + margin-bottom: $baseline; + } + + fieldset { + margin: 0; + padding-top: 0; + padding-bottom: $baseline; + } + + .list-input { + margin: 0; + padding: 0; + list-style: none; + } + + // field groups + .field-group { + @include clearfix(); + margin: 0 0 $baseline 0; + + .field { + display: block; + float: left; + border-bottom: none; + margin: 0 ($baseline*1.5) 0 0; + padding-bottom: 0; + + input, textarea { + width: 100%; + font-weight: 600; + } + } + + &:last-child { + margin-bottom: 0; + } + } + + // individual fields + .field { + margin: 0 0 $baseline 0; + + // elements + label, input, textarea { + @include border-radius(0); + display: block; + height: auto; + font-family: $sans-serif; + font-style: normal; + font-weight: 500; + color: $m-gray-d2; + } + + label { + @include transition(color 0.15s ease-in-out); + margin: 0 0 ($baseline/4) 0; + color: tint($black, 20%); + } + + .tip { + @include transition(color 0.15s ease-in-out); + display: block; + margin-top: ($baseline/4); + color: tint($m-gray, 50%); + font-size: em(13); + } + + input, textarea { + width: 100%; + margin: 0; + padding: ($baseline/2) ($baseline*.75); + + &.long { + width: 100%; + } + + &.short { + width: 25%; + } + } + + textarea.long { + height: ($baseline*5); + } + + &:last-child { + margin-bottom: 0; + } + + // types - password + + // types - select + &.select { + + select { + width: 100%; + } + } + + // types - checkboxes/radio buttons + &.checkbox { + + input[type="checkbox"] { + display: inline-block; + width: auto; + margin-right: ($baseline/4); + } + + label { + display: inline-block; + } + } + + // states - all + &.disabled, &.submitted { + color: rgba(0,0,0,.25); + + label { + cursor: text; + + &:after { + margin-left: ($baseline/4); + } + } + + textarea, input { + background: $white; + color: rgba(0,0,0,.25); + } + } + + // states - focused + &.is-focused { + + label { + color: $m-blue-s1; + } + + .tip { + color: $m-blue-s1; + } + } + + // states - disabled + &.disabled { + label:after { + color: rgba(0,0,0,.35); + content: "(Disabled Currently)"; + } + } + + &.error { + + label { + color: $red; + } + + input, textarea { + border-color: tint($red,50%); + } + } + + &.required { + + label { + font-weight: 600; + + a { + font-weight: 600 !important; + } + } + + label:after { + margin-left: ($baseline/4); + content: "*"; + } + } + } + } + + // forms - actions + .form-actions { + @include clearfix(); + + button[type="submit"] { + @extend .button-primary; + + &:disabled, &.is-disabled { + opacity: 0.3; + cursor: default !important; + } + } + + .action-primary { + float: left; + width: flex-grid(8,8); + margin-right: flex-gutter(0); + } + + .action-secondary { + display: block; + float: right; + width: flex-grid(3,8); + margin: $baseline $baseline 0 0; + font-size: em(14); + text-align: right; + } + + &.error { + + } + } + + // forms - messages/status + .status { + @include box-sizing(border-box); + margin: 0 0 $baseline 0; + border-bottom: 3px solid shade($yellow, 10%); + padding: $baseline $baseline; + background: tint($yellow,20%); + + .message-title { + @extend .heading-4; + margin: 0 0 ($baseline/4) 0; + font-size: em(14); + font-weight: 600; + color: $m-gray-d2 !important; + } + + .message-copy { + @extend .body-text; + margin: 0 !important; + padding: 0; + list-style: none; + + li { + margin: 0 0 ($baseline/4) 0; + } + } + } + + .submission-error, .system-error { + @include box-shadow(inset 0 -1px 2px 0 tint($red, 85%)); + border-bottom: 3px solid shade($red, 10%); + background: tint($red,95%); + + .message-title { + color: shade($red, 10%) !important; + } + + .message-copy { + + } + } + + // misc + .orn-plus { + color: $white; + padding: 0 $baseline/4; + } + + #register-form, #login-form, #passwordreset-form { + + .status.message { + display: none; + + &.is-shown { + display: block; + } + } + } +} + +// ===== + +// login +.view-login { + + header.global .nav-courseware .cta-login { + display: none; + } + + .introduction { + padding: 0; + + header { + height: 120px; + border-bottom: 1px solid $m-gray; + background: transparent url("../images/bg-banner-login.png") 0 0 no-repeat; + } + } +} + +// register +.view-register { + + .introduction { + padding: 0; + + header { + height: 120px; + border-bottom: 1px solid $m-gray; + background: transparent url("../images/bg-banner-register.png") 0 0 no-repeat; + } + } +} + +// password reset +.view-passwordreset { + background: $m-gray-l2; + + header.global { + + h1 { + float: none; + } + } + + .introduction { + width: auto; + padding: 0; + + header h1 { + margin: 0; + } + } + + .content { + margin-top: 0; + } +} + +// modal password reset form +#forgot-password-modal { + @include border-radius(2px); + + + .inner-wrapper { + @include border-radius(2px); + background: $white; + padding-bottom: 0 !important; + } + + #password-reset { + padding: $baseline; + + header { + margin: 0; + padding: 0; + + &:before { + background-image: none; + } + + h2 { + @extend .heading-2; + text-align: left; + } + } + + .message { + margin: $baseline 0 0 0; + } + + fieldset { + margin-bottom: ($baseline/2); + padding: 0; + } + + .instructions p { + margin-bottom: ($baseline/4); + } + + form { + @include border-radius(0); + @include box-shadow(none); + margin: 0; + border: none; + padding: 0; + + .field { + + &.text, &.email, &.textarea { + + input { + background: #fafafa; + margin-bottom: 0; + } + } + } + + .form-actions { + padding: 0 !important; + + .action-primary { + float: none; + display: block !important; + width: 100%; + } + } + } + } + + .modal-form-error { + @extend .body-text; + @include box-shadow(inset 0 -1px 2px 0 tint($red, 85%)); + @include box-sizing(border-box); + margin: $baseline 0 ($baseline/2) 0 !important; + padding: $baseline; + border: none; + border-bottom: 3px solid shade($red, 10%); + background: tint($red,95%); + } +} diff --git a/lms/static/sass/multicourse/_course_about.scss b/lms/static/sass/multicourse/_course_about.scss index d23917fe27..0008bf1efe 100644 --- a/lms/static/sass/multicourse/_course_about.scss +++ b/lms/static/sass/multicourse/_course_about.scss @@ -4,11 +4,11 @@ } header.course-profile { - background: rgb(245,245,245); - @include background-image(url('/static/images/homepage-bg.jpg')); + background: $course-profile-bg; + @include background-image(url($homepage-bg-image)); background-size: cover; @include box-shadow(0 1px 80px 0 rgba(0,0,0, 0.5)); - border-bottom: 1px solid rgb(100,100,100); + border-bottom: 1px solid $border-color-3; @include box-shadow(inset 0 1px 5px 0 rgba(0,0,0, 0.1)); height: 280px; margin-top: -69px; @@ -18,8 +18,8 @@ width: 100%; .intro-inner-wrapper { - background: rgba(255,255,255, 0.93); - border: 1px solid rgb(100,100,100); + background: $course-header-bg; + border: 1px solid $border-color-3; @include box-shadow(0 4px 25px 0 rgba(0,0,0, 0.5)); @include box-sizing(border-box); @include clearfix; @@ -44,7 +44,7 @@ z-index: 2; > hgroup { - border-bottom: 1px solid rgb(210,210,210); + border-bottom: 1px solid $border-color-2; @include box-shadow(0 1px 0 0 rgba(255,255,255, 0.6)); margin-bottom: 20px; padding-bottom: 20px; @@ -68,7 +68,7 @@ text-transform: none; &:hover { - color: $blue; + color: $link-color; } } } @@ -85,7 +85,7 @@ text-transform: none; &:hover { - color: $blue; + color: $link-color; } } } @@ -99,7 +99,7 @@ width: flex-grid(12); > a.find-courses, a.register { - @include button(shiny, $blue); + @include button(shiny, $button-color); @include box-sizing(border-box); @include border-radius(3px); display: block; @@ -122,7 +122,7 @@ } strong { - @include button(shiny, $blue); + @include button(shiny, $button-color); @include box-sizing(border-box); @include border-radius(3px); display: block; @@ -140,10 +140,10 @@ } span.register { - background: lighten($blue, 20%); - border: 1px solid $blue; + background: $button-archive-color; + border: 1px solid darken($button-archive-color, 50%); @include box-sizing(border-box); - color: darken($blue, 20%); + color: darken($button-archive-color, 50%); display: block; letter-spacing: 1px; padding: 10px 0px 8px; @@ -154,6 +154,15 @@ @include transition(); width: flex-grid(5, 8); } + + #register_error { + background: $error-red; + border: 1px solid rgb(202, 17, 17); + color: rgb(143, 14, 14); + display: none; + padding: 12px; + margin-top: 5px; + } } } @@ -167,7 +176,7 @@ z-index: 2; .hero { - border: 1px solid rgb(100,100,100); + border: 1px solid $border-color-3; height: 100%; overflow: hidden; position: relative; @@ -226,7 +235,7 @@ @include clearfix; nav { - border-bottom: 1px solid rgb(220,220,220); + border-bottom: 1px solid $border-color-2; @include box-sizing(border-box); @include clearfix; margin: 40px 0; @@ -253,7 +262,7 @@ } &:hover, &.active { - border-color: rgb(200,200,200); + border-color: $border-color-2; color: $base-font-color; text-decoration: none; } @@ -272,7 +281,9 @@ } .course-staff { + .teacher { + @include clearfix; margin-bottom: 40px; h3 { @@ -285,7 +296,7 @@ .teacher-image { background: rgb(255,255,255); - border: 1px solid rgb(200,200,200); + border: 1px solid $border-color-2; height: 115px; float: left; margin: 0 15px 0px 0; @@ -312,7 +323,7 @@ } } } - + .faq { @include clearfix; @@ -340,7 +351,7 @@ > section { @include box-shadow(inset 0 0 3px 0 rgba(0,0,0, 0.15)); - border: 1px solid rgb(200,200,200); + border: 1px solid $border-color-2; &.course-summary { padding: 16px 20px 30px; @@ -390,7 +401,7 @@ } a.university-name { - border-right: 1px solid rgb(200,200,200); + border-right: 1px solid $border-color-2; color: $base-font-color; font-family: $sans-serif; font-style: italic; @@ -418,7 +429,7 @@ &:hover { .sharing-message { - opacity: 1; + opacity: 1.0; top: 56px; } } @@ -459,7 +470,7 @@ width: 44px; &:hover { - opacity: 1; + opacity: 1.0; } img { @@ -487,12 +498,12 @@ li { @include clearfix; - border-bottom: 1px dotted rgb(220,220,220); + border-bottom: 1px dotted $border-color-2; margin-bottom: 20px; padding-bottom: 10px; &.prerequisites { - border: 1px solid rgb(220,220,220); + border: 1px solid $border-color-2; margin: 0 -10px 0; padding: 10px; @@ -503,7 +514,7 @@ &:hover { .icon { - opacity: 1; + opacity: 1.0; } } diff --git a/lms/static/sass/multicourse/_courses.scss b/lms/static/sass/multicourse/_courses.scss index 45ecfcd23f..ac31da4d2a 100644 --- a/lms/static/sass/multicourse/_courses.scss +++ b/lms/static/sass/multicourse/_courses.scss @@ -1,12 +1,13 @@ .find-courses, .university-profile { - background: rgb(252,252,252); + background: $course-profile-bg; padding-bottom: 60px; header.search { - background: rgb(240,240,240); + background: $course-profile-bg; background-size: cover; + @include background-image(url($homepage-bg-image)); background-position: center top !important; - border-bottom: 1px solid rgb(100,100,100); + border-bottom: 1px solid $border-color-3; @include box-shadow(inset 0 -1px 8px 0 rgba(0,0,0, 0.2), inset 0 1px 12px 0 rgba(0,0,0, 0.3)); height: 430px; margin-top: -69px; @@ -24,8 +25,8 @@ > hgroup { background: #FFF; - background: rgba(255,255,255, 0.93); - border: 1px solid rgb(100,100,100); + background: $course-header-bg; + border: 1px solid $border-color-3; @include box-shadow(0 4px 25px 0 rgba(0,0,0, 0.5)); padding: 20px 30px; position: relative; @@ -83,7 +84,7 @@ } section.message { - border-top: 1px solid rgb(220,220,220); + border-top: 1px solid $border-color-2; @include clearfix; margin-top: 20px; padding-top: 60px; diff --git a/lms/static/sass/multicourse/_dashboard.scss b/lms/static/sass/multicourse/_dashboard.scss index 4555a426d3..c0dac89199 100644 --- a/lms/static/sass/multicourse/_dashboard.scss +++ b/lms/static/sass/multicourse/_dashboard.scss @@ -1,6 +1,6 @@ .dashboard { @include clearfix; - padding: 60px 0px 120px; + padding: 60px 0 0 0; .dashboard-banner { background: $yellow; @@ -30,8 +30,9 @@ width: flex-grid(3); header.profile { - @include background-image(linear-gradient(-90deg, rgb(255,255,255), rgb(245,245,245))); - border: 1px solid rgb(200,200,200); + @include background-image($dashboard-profile-header-image); + background-color: $dashboard-profile-header-color; + border: 1px solid $border-color-2; @include border-radius(4px); @include box-sizing(border-box); width: flex-grid(12); @@ -53,8 +54,8 @@ padding: 0px 10px; > ul { - background: rgb(252,252,252); - border: 1px solid rgb(200,200,200); + background: $dashboard-profile-color; + border: 1px solid $border-color-2; border-top: none; //@include border-bottom-radius(4px); @include box-sizing(border-box); @@ -66,14 +67,14 @@ li { @include clearfix; - border-bottom: 1px dotted rgb(220,220,220); + border-bottom: 1px dotted $border-color-2; list-style: none; margin-bottom: 15px; padding-bottom: 17px; &:hover { .title .icon { - opacity: 1; + opacity: 1.0; } } @@ -128,8 +129,8 @@ .news-carousel { @include clearfix; margin: 30px 10px 0; - border: 1px solid rgb(200,200,200); - background: rgb(252,252,252); + border: 1px solid $border-color-2; + background: $dashboard-profile-color; @include box-shadow(inset 0 0 3px 0 rgba(0,0,0, 0.15)); * { @@ -156,14 +157,14 @@ width: 11px; height: 11px; border-radius: 11px; - background: $light-gray; + background: $dot-color; &:hover { - background: #ccc; + background: $lighter-base-font-color; } &.current { - background: $blue; + background: $link-color; } } @@ -201,7 +202,7 @@ img { width: 100%; - border: 1px solid $light-gray; + border: 1px solid $border-color-1; } } @@ -229,7 +230,7 @@ width: flex-grid(9); > header { - border-bottom: 1px solid rgb(210,210,210); + border-bottom: 1px solid $border-color-2; margin-bottom: 30px; } @@ -246,8 +247,9 @@ a { background: rgb(240,240,240); - @include background-image(linear-gradient(-90deg, rgb(245,245,245) 0%, rgb(243,243,243) 50%, rgb(237,237,237) 50%, rgb(235,235,235) 100%)); - border: 1px solid rgb(220,220,220); + @include background-image($button-bg-image); + background-color: $button-bg-color; + border: 1px solid $border-color-2; @include border-radius(4px); @include box-shadow(0 1px 8px 0 rgba(0,0,0, 0.1)); @include box-sizing(border-box); @@ -260,7 +262,7 @@ text-shadow: 0 1px rgba(255,255,255, 0.6); &:hover { - color: $blue; + color: $link-color; text-decoration: none; } } @@ -272,7 +274,7 @@ margin-right: flex-gutter(); margin-bottom: 50px; padding-bottom: 50px; - border-bottom: 1px solid $light-gray; + border-bottom: 1px solid $border-color-1; position: relative; width: flex-grid(12); z-index: 20; @@ -327,7 +329,7 @@ color: $lighter-base-font-color; } - h3 a { + h3 a, h3 span { display: block; margin-bottom: 10px; font-family: $sans-serif; @@ -343,7 +345,7 @@ .course-status { background: $yellow; - border: 1px solid rgb(200,200,200); + border: 1px solid $border-color-2; @include box-shadow(0 1px 0 0 rgba(255,255,255, 0.6)); margin-top: 17px; margin-right: flex-gutter(); @@ -362,7 +364,7 @@ .course-status-completed { background: #ccc; - color: #fff; + color: $very-light-text; p { color: #222; @@ -374,7 +376,7 @@ } .enter-course { - @include button(simple, $blue); + @include button(simple, $button-color); @include box-sizing(border-box); @include border-radius(3px); display: block; @@ -386,7 +388,7 @@ margin-top: 16px; &.archived { - @include button(simple, #eee); + @include button(simple, $button-archive-color); font: normal 15px/1.6rem $sans-serif; padding: 6px 32px 7px; diff --git a/lms/static/sass/multicourse/_home.scss b/lms/static/sass/multicourse/_home.scss index b5546aa470..d4def21f5c 100644 --- a/lms/static/sass/multicourse/_home.scss +++ b/lms/static/sass/multicourse/_home.scss @@ -7,15 +7,15 @@ } > header { - background: rgb(255,255,255); - @include background-image(url('/static/images/homepage-bg.jpg')); + background: $dashboard-profile-color; + @include background-image(url($homepage-bg-image)); background-size: cover; - border-bottom: 1px solid rgb(80,80,80); - @include box-shadow(0 1px 0 0 rgba(255,255,255, 0.9), inset 0 -1px 5px 0 rgba(0,0,0, 0.1)); + border-bottom: 1px solid $border-color-3; + @include box-shadow(0 1px 0 0 $course-header-bg, inset 0 -1px 5px 0 rgba(0,0,0, 0.1)); @include clearfix; height: 460px; - margin-top: -69px; overflow: hidden; + margin-top: -69px; padding: 0px; width: flex-grid(12); @@ -31,8 +31,8 @@ .title { background: #FFF; - background: rgba(255,255,255, 0.93); - border: 1px solid rgb(100,100,100); + background: $course-header-bg; + border: 1px solid $border-color-3; @include box-shadow(0 4px 25px 0 rgba(0,0,0, 0.5)); @include box-sizing(border-box); min-height: 120px; @@ -53,7 +53,7 @@ @include box-sizing(border-box); @include inline-block; left: 0px; - opacity: 1; + opacity: 1.0; padding: 20px 30px; top: 0px; @include transition(all, 0.2s, linear); @@ -80,8 +80,8 @@ .media { background: #FFF; - background: rgba(255,255,255, 0.93); - border: 1px solid rgb(100,100,100); + background: $course-header-bg; + border: 1px solid $border-color-3; border-left: 0; @include box-sizing(border-box); // @include box-shadow(0 4px 25px 0 rgba(0,0,0, 0.5)); @@ -101,7 +101,7 @@ height: 100%; overflow: hidden; position: relative; - background: url('../images/courses/video-thumb.jpg') center no-repeat; + background: url($video-thumb-url) center no-repeat; @include background-size(cover); .play-intro { @@ -164,9 +164,9 @@ > h2 { @include background-image(linear-gradient(-90deg, rgb(250,250,250), rgb(230,230,230))); - border: 1px solid rgb(200,200,200); + border: 1px solid $border-color-2; @include border-radius(4px); - border-top-color: rgb(190,190,190); + border-top-color: $border-color-1; @include box-shadow(inset 0 0 0 1px rgba(255,255,255, 0.4), 0 0px 12px 0 rgba(0,0,0, 0.2)); color: $lighter-base-font-color; letter-spacing: 1px; @@ -180,7 +180,7 @@ } .university-partners { - border-bottom: 1px solid rgb(210,210,210); + border-bottom: 1px solid $border-color-2; margin-bottom: 0px; overflow: hidden; position: relative; @@ -312,7 +312,7 @@ text-decoration: none; &::before { - opacity: 1; + opacity: 1.0; } .name { @@ -366,13 +366,13 @@ } .more-info { - border: 1px solid rgb(200,200,200); + border: 1px solid $border-color-2; margin-bottom: 80px; width: flex-grid(12); header { @include background-image(linear-gradient(-90deg, rgb(250,250,250), rgb(230,230,230))); - border-bottom: 1px solid rgb(200,200,200); + border-bottom: 1px solid $border-color-2; @include clearfix; padding: 10px 20px 8px; position: relative; @@ -415,14 +415,14 @@ width: flex-grid(12); .blog-posts { - border-bottom: 1px solid rgb(220,220,220); + border-bottom: 1px solid $border-color-2; margin-bottom: 20px; padding-bottom: 20px; @include clearfix; > article { border: 1px dotted transparent; - border-color: rgb(220,220,220); + border-color: $border-color-2; @include box-sizing(border-box); @include clearfix; float: left; @@ -432,8 +432,8 @@ width: flex-grid(4); &:hover { - background: rgb(248,248,248); - border: 1px solid rgb(220,220,220); + background: $body-bg; + border: 1px solid $border-color-2; @include box-shadow(inset 0 0 3px 0 rgba(0,0,0, 0.1)); } @@ -442,7 +442,7 @@ } .post-graphics { - border: 1px solid rgb(190,190,190); + border: 1px solid $border-color-1; @include box-sizing(border-box); display: block; float: left; diff --git a/lms/static/sass/multicourse/_password_reset.scss b/lms/static/sass/multicourse/_password_reset.scss index a2365e3e3e..9f145351d1 100644 --- a/lms/static/sass/multicourse/_password_reset.scss +++ b/lms/static/sass/multicourse/_password_reset.scss @@ -73,6 +73,7 @@ input[type="email"], input[type="text"], input[type="password"] { + border: 1px solid red !important; background: rgb(255,255,255); display: block; height: 45px; diff --git a/lms/static/sass/multicourse/_testcenter-register.scss b/lms/static/sass/multicourse/_testcenter-register.scss index 6d85fc167f..01405d7fc1 100644 --- a/lms/static/sass/multicourse/_testcenter-register.scss +++ b/lms/static/sass/multicourse/_testcenter-register.scss @@ -1,10 +1,5 @@ -// ========== - -$baseline: 20px; -$yellow: rgb(255, 235, 169); -$red: rgb(178, 6, 16); - -// ========== +// Pearson VUE Test Center Registration +// ===== .testcenter-register { @include clearfix; diff --git a/lms/static/sass/shared/_course_object.scss b/lms/static/sass/shared/_course_object.scss index e99559a49f..bd4a8dc049 100644 --- a/lms/static/sass/shared/_course_object.scss +++ b/lms/static/sass/shared/_course_object.scss @@ -31,8 +31,8 @@ } .course { - background: rgb(250,250,250); - border: 1px solid rgb(180,180,180); + background: $body-bg; + border: 1px solid $border-color-1; @include border-radius(2px); @include box-sizing(border-box); @include box-shadow(0 1px 10px 0 rgba(0,0,0, 0.15), inset 0 0 0 1px rgba(255,255,255, 0.9)); @@ -42,7 +42,7 @@ @include transition(all, 0.15s, linear); .status { - background: $blue; + background: $link-color; color: white; font-size: 10px; left: 10px; @@ -55,7 +55,7 @@ } .status:after { - border-bottom: 6px solid shade($blue, 50%); + border-bottom: 6px solid shade($link-color, 50%); border-right: 6px solid transparent; content: ""; display: block; @@ -90,7 +90,7 @@ } .inner-wrapper { - border: 1px solid rgba(255,255,255, 1); + border: 1px solid $border-color-4; height: 100%; height: 200px; overflow: hidden; @@ -116,12 +116,12 @@ text-decoration: none; .info-link { - color: $blue; - opacity: 1; + color: $link-color; + opacity: 1.0; } h2 { - color: $blue; + color: $link-color; } } @@ -176,7 +176,7 @@ // } .info { - background: rgb(255,255,255); + background: $content-wrapper-bg; height: 220px + 130px; left: 0px; position: absolute; @@ -221,14 +221,14 @@ width: 100%; .university { - border-right: 1px solid rgb(200,200,200); + border-right: 1px solid $border-color-2; color: $lighter-base-font-color; letter-spacing: 1px; margin-right: 10px; padding-right: 10px; &:hover { - color: $blue; + color: $link-color; } } @@ -240,9 +240,9 @@ } &:hover { - background: rgb(245,245,245); - border-color: rgb(170,170,170); - @include box-shadow(0 1px 16px 0 rgba($blue, 0.4)); + background: $course-profile-bg; + border-color: $border-color-1; + @include box-shadow(0 1px 16px 0 rgba($shadow-color, 0.4)); .info { top: -150px; diff --git a/lms/static/sass/shared/_footer.scss b/lms/static/sass/shared/_footer.scss index a418b887ad..864cf57d03 100644 --- a/lms/static/sass/shared/_footer.scss +++ b/lms/static/sass/shared/_footer.scss @@ -1,179 +1,157 @@ -footer { - background: transparent; - border-top: 1px solid rgb(200,200,200); - @include box-shadow(inset 0 1px 3px 0 rgba(0,0,0, 0.1)); - margin: 0 auto; - width: flex-grid(12); +.wrapper-footer { + @include box-shadow(0 -1px 5px 0 rgba(0,0,0, 0.1)); + border-top: 1px solid tint($m-gray,50%); + padding: 25px ($baseline/2) ($baseline*1.5) ($baseline/2); + background: $white; - &.fixed-bottom { - bottom: 0px; - max-width: 100%; - position: absolute; - } - - nav { - max-width: 1200px; - margin: 0 auto; - padding: 30px 10px 0; + footer { + @include clearfix(); max-width: grid-width(12); min-width: 760px; + width: flex-grid(12); + margin: 0 auto; - .top { - border-bottom: 1px solid rgb(200,200,200); - @include clearfix; - padding-bottom: 30px; - width: flex-grid(12); - text-align: center; + p, ol, ul { + font-family: $sans-serif; + } - ol { - float: right; + a { + @include transition(color 0.15s ease-in-out, border 0.15s ease-in-out); + + &:link, &:visited, &:hover, &:active { + border-bottom: none; + color: $m-blue; + text-decoration: none !important; + font-family: $sans-serif; + } + + &:hover, &:active { + border-bottom: 1px dotted $m-blue-s1; + color: $m-blue-s1; + } + } + + // colophon + .colophon { + margin-right: flex-gutter(); + width: flex-grid(8,12); + float: left; + + .nav-colophon { + @include clearfix(); + margin: ($baseline/4) 0 ($baseline*1.5) 0; li { - @include inline-block; - list-style: none; - padding: 0px 15px; - position: relative; - vertical-align: middle; - - &::after { - @extend .faded-vertical-divider; - content: ""; - display: block; - height: 30px; - right: 0px; - position: absolute; - top: -5px; - width: 1px; - } - - a:link, a:visited { - color: $lighter-base-font-color; - padding: 6px 0px; - } - } - } - - .primary { - @include clearfix; - float: left; - - a.logo { - @include background-image(url('/static/images/logo.png')); - background-position: 0 -24px; - background-repeat: no-repeat; - @include inline-block; - height: 22px; - margin-right: 15px; - margin-top: 2px; - padding-right: 15px; - position: relative; - width: 47px; - vertical-align: middle; - @include transition(none); - - &:hover { - background-position: 0 0; - } - - &::after { - @extend .faded-vertical-divider; - content: ""; - display: block; - height: 30px; - right: 0px; - position: absolute; - top: -3px; - width: 1px; - } - } - - a { - color: $lighter-base-font-color; - @include inline-block; - margin-right: 20px; - padding-top: 2px; - vertical-align: middle; - - &:hover { - color: $base-font-color; - text-decoration: none; - } - } - } - - .social { - float: right; - - &.social { - border: none; - margin: 0 0 0 5px; - padding: 0; + float: left; + margin-right: ($baseline*0.75); a { - @include inline-block; - opacity: 0.3; - @include transition(all, 0.1s, linear); + color: tint($black, 20%); - &:hover { - opacity: 1; + &:hover, &:active { + color: $m-blue-s1; } } + + &:last-child { + margin-right: 0; + } + } + } + + .colophon-about { + @include clearfix(); + + img { + width: 68px; + height: 34px; + margin-right: 0; + float: left; + } + + p { + float: left; + width: flex-grid(6,8); + margin-left: $baseline; + padding-left: $baseline; + font-size: em(13); + background: transparent url(/static/images/bg-footer-divider.jpg) 0 0 no-repeat; } } } - .bottom { - @include clearfix; - opacity: 0.8; - padding: 10px 0px 30px; - @include transition(all, 0.15s, linear); - width: flex-grid(12); + // references + .references { + margin: -10px 0 0 0; + width: flex-grid(4,12); + float: right; - &:hover { - opacity: 1; + .nav-social { + margin: 0; + text-align: right; + + li { + display: inline-block; + + &:last-child { + margin-right: 0; + } + + a { + display: block; + + &:hover, &:active { + border: none; + } + } + + img { + display: block; + } + } } .copyright { - float: left; - - p { - color: $lighter-base-font-color; - font-style: italic; - @include inline-block; - margin: 0 auto; - padding-top: 1px; - text-align: center; - vertical-align: middle; - - a { - color: $lighter-base-font-color; - font-style: italic; - margin-left: 5px; - - &:hover { - color: $blue; - } - } - } + margin: -2px 0 8px 0; + font-size: em(11); + color: tint($m-gray,50%); + text-align: right; } - .secondary { - float: right; - text-align: left; + .nav-legal { + @include clearfix(); + text-align: right; - a { - color: $lighter-base-font-color; - font-family: $serif; - font-style: italic; - line-height: 1.6em; - margin-left: 20px; - text-transform: lowercase; + li { + display: inline-block; + font-size: em(11); - &:hover { - color: $blue; + a { + display: block; + } + } + + .nav-legal-01 a { + + &:after { + margin-left: 5px; + content: "-"; } } } } + + } +} + +// marketing site design syncing +.view-register, .view-login { + + .wrapper-footer footer { + width: 960px; + + .colophon-about img { + margin-top: ($baseline*1.5); + } } } diff --git a/lms/static/sass/shared/_forms.scss b/lms/static/sass/shared/_forms.scss index 79d476f420..3350081850 100644 --- a/lms/static/sass/shared/_forms.scss +++ b/lms/static/sass/shared/_forms.scss @@ -15,8 +15,8 @@ input[type="text"], input[type="email"], input[type="password"], input[type="tel"] { - background: rgb(250,250,250); - border: 1px solid rgb(200,200,200); + background: $form-bg-color; + border: 1px solid $border-color-2; @include border-radius(3px); @include box-shadow(0 1px 0 0 rgba(255,255,255, 0.6), inset 0 0 3px 0 rgba(0,0,0, 0.1)); @include box-sizing(border-box); @@ -31,8 +31,8 @@ input[type="tel"] { } &:focus { - border-color: lighten($blue, 20%); - @include box-shadow(0 0 6px 0 rgba($blue, 0.4), inset 0 0 4px 0 rgba(0,0,0, 0.15)); + border-color: darken($button-archive-color, 50%); + @include box-shadow(0 0 6px 0 darken($button-archive-color, 50%), inset 0 0 4px 0 rgba(0,0,0, 0.15)); outline: none; } } @@ -46,7 +46,7 @@ input[type="button"], button, .button { @include border-radius(3px); - @include button(shiny, $blue); + @include button(shiny, $button-color); font: normal 1.2rem/1.6rem $sans-serif; letter-spacing: 1px; padding: 4px 20px; diff --git a/lms/static/sass/shared/_header.scss b/lms/static/sass/shared/_header.scss index 688ffbf57e..0608a8faf4 100644 --- a/lms/static/sass/shared/_header.scss +++ b/lms/static/sass/shared/_header.scss @@ -1,8 +1,8 @@ header.global { - border-bottom: 1px solid rgb(190,190,190); + border-bottom: 1px solid $m-gray; @include box-shadow(0 1px 5px 0 rgba(0,0,0, 0.1)); - @include background-image(linear-gradient(-90deg, rgba(255,255,255, 1), rgba(230,230,230, 0.9))); - height: 68px; + background: $white; + height: 76px; position: relative; width: 100%; z-index: 10; @@ -11,40 +11,16 @@ header.global { @include clearfix; height: 40px; margin: 0 auto; - max-width: 1200px; - padding: 14px 10px 0px; + padding: 18px 10px 0px; max-width: grid-width(12); min-width: 760px; } h1.logo { float: left; - margin: 0px 15px 0px 0px; - padding-right: 20px; + margin: -2px 39px 0px 0px; position: relative; - &::before { - @extend .faded-vertical-divider; - content: ""; - display: block; - height: 50px; - position: absolute; - right: 1px; - top: -8px; - width: 1px; - } - - &::after { - @extend .faded-vertical-divider-light; - content: ""; - display: block; - height: 50px; - position: absolute; - right: 0px; - top: -12px; - width: 1px; - } - a { display: block; } @@ -77,8 +53,7 @@ header.global { li.secondary { > a { - color: $lighter-base-font-color; - color: $blue; + color: $link-color; display: block; font-family: $sans-serif; @include inline-block; @@ -101,9 +76,9 @@ header.global { margin-right: 5px; > a { - @include background-image(linear-gradient(#fff 0%, rgb(250,250,250) 50%, rgb(237,237,237) 50%, rgb(220,220,220) 100%)); - border: 1px solid transparent; - border-color: rgb(200,200,200); + @include background-image($button-bg-image); + background-color: $button-bg-color; + border: 1px solid $border-color-2; @include border-radius(3px); @include box-sizing(border-box); @include box-shadow(0 1px 0 0 rgba(255,255,255, 0.6)); @@ -124,7 +99,7 @@ header.global { } &:hover, &.active { - background: #FFF; + background: $button-bg-hover-color; } } } @@ -182,10 +157,10 @@ header.global { } ul.dropdown-menu { - background: rgb(252,252,252); + background: $border-color-4; @include border-radius(4px); @include box-shadow(0 2px 24px 0 rgba(0,0,0, 0.3)); - border: 1px solid rgb(100,100,100); + border: 1px solid $border-color-3; display: none; padding: 5px 10px; position: absolute; @@ -201,12 +176,12 @@ header.global { &::before { background: transparent; border: { - top: 6px solid rgba(252,252,252, 1); - right: 6px solid rgba(252,252,252, 1); + top: 6px solid $border-color-4; + right: 6px solid $border-color-4; bottom: 6px solid transparent; left: 6px solid transparent; } - @include box-shadow(1px 0 0 0 rgb(0,0,0), 0 -1px 0 0 rgb(0,0,0)); + @include box-shadow(1px 0 0 0 $border-color-3, 0 -1px 0 0 $border-color-3); content: ""; display: block; height: 0px; @@ -219,7 +194,7 @@ header.global { li { display: block; - border-top: 1px dotted rgba(200,200,200, 1); + border-top: 1px dotted $border-color-2; @include box-shadow(inset 0 1px 0 0 rgba(255,255,255, 0.05)); &:first-child { @@ -231,7 +206,7 @@ header.global { border: 1px solid transparent; @include border-radius(3px); @include box-sizing(border-box); - color: $blue; + color: $link-color; cursor: pointer; display: block; margin: 5px 0px; @@ -251,4 +226,104 @@ header.global { } } } + + .nav-global { + margin-top: ($baseline/2); + list-style: none; + + li { + display: inline-block; + margin: 0 $baseline+1 0 0; + font-size: em(14); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0 !important; + + &:last-child { + margin-right: 0; + } + + a { + display:block; + padding: ($baseline/4); + color: $m-gray-d1; + font-weight: 600; + + &:hover, &:active { + text-decoration: none; + color: $m-blue-s1; + } + } + + &.active { + + a { + text-decoration: none; + color: $m-blue-s1; + } + } + } + + // logged in + &.authenticated { + + } + } + + .nav-courseware { + float: right; + margin-top: ($baseline/4); + list-style: none; + + li { + display: inline-block; + + a { + @include border-radius(0); + @include linear-gradient($m-blue-s1 5%, $m-blue-d1 95%); + display: inline-block; + padding: $baseline/2 $baseline*2.5; + text-transform: lowercase; + color: $white; + letter-spacing: 0.1rem; + font-weight: 300; + cursor: pointer; + text-align: center; + border: none !important; + text-shadow: none; + letter-spacing: 0.1rem; + font-size: 14px; + box-shadow: none !important; + + &:hover { + text-decoration: none; + } + } + } + + // logged in + &.authenticated { + + } + } +} + +// marketing site design syncing +.view-register, .view-login { + + header.global nav { + width: 960px; + } +} + +// page-based nav states +.view-howitworks .nav-global-01, +.view-courses .nav-global-02, +.view-schools .nav-global-03, +.view-register .nav-global-04 { + + a { + text-decoration: none; + color: $m-blue-s1 !important; + } } diff --git a/lms/static/sass/shared/_modal.scss b/lms/static/sass/shared/_modal.scss index bfa803fee2..9777c582da 100644 --- a/lms/static/sass/shared/_modal.scss +++ b/lms/static/sass/shared/_modal.scss @@ -52,7 +52,7 @@ } .inner-wrapper { - background: rgb(245,245,245); + background: $modal-bg-color; @include border-radius(0px); border: 1px solid rgba(0, 0, 0, 0.9); @include box-shadow(inset 0 1px 0 0 rgba(255, 255, 255, 0.7)); @@ -149,13 +149,13 @@ } label { - color: #999; + color: $text-color; &.field-error { display: block; color: #8F0E0E; - + input { + + input, + textarea { border: 1px solid #CA1111; color: #8F0E0E; } @@ -300,8 +300,29 @@ } } } + + #help_wrapper { + padding: 0 ($baseline*1.5) ($baseline*1.5) ($baseline*1.5); + + header { + margin-bottom: $baseline; + padding-right: 0; + padding-left: 0; + } + + + } + + .tip { + font-size: 12px; + display: block; + color: $dark-gray; + } + + } .leanModal_box { @extend .modal; } + diff --git a/lms/templates/accounts_login.html b/lms/templates/accounts_login.html deleted file mode 100644 index db9cca2b22..0000000000 --- a/lms/templates/accounts_login.html +++ /dev/null @@ -1,35 +0,0 @@ -<%! from django.core.urlresolvers import reverse %> -<%inherit file="main.html" /> -<%namespace name='static' file='static_content.html'/> - -<%block name="headextra"> - - - - - - diff --git a/lms/templates/courseware/course_about.html b/lms/templates/courseware/course_about.html index dc1dc17532..941bf61698 100644 --- a/lms/templates/courseware/course_about.html +++ b/lms/templates/courseware/course_about.html @@ -13,42 +13,26 @@ <%block name="js_extra"> - % if not registered: - %if user.is_authenticated(): - ## If the user is authenticated, clicking the enroll button just submits a form - - %else: - ## If the user is not authenticated, clicking the enroll button pops up the register - ## field. We also slip in the registration fields into the login/register fields so - ## the user is automatically registered after logging in / registering - - %endif - %endif + $('#class_enroll_form').on('ajax:complete', function(event, xhr) { + if(xhr.status == 200) { + location.href = "${reverse('dashboard')}"; + } else if (xhr.status == 403) { + location.href = "${reverse('register_user')}?course_id=${course.id}&enrollment_action=enroll"; + } else { + $('#register_error').html( + (xhr.responseText ? xhr.responseText : 'An error occurred. Please try again later.') + ).css("display", "block"); + } + }); + })(this) + @@ -66,8 +50,7 @@
- %if user.is_authenticated(): - %if registered: + %if user.is_authenticated() and registered: %if show_courseware_link: %endif @@ -76,16 +59,9 @@ View Courseware %endif - %else: - Register for ${course.number} -
- %endif %else: - Log In -% endif - to enroll.'>Register for ${course.number} + Register for ${course.number} +
%endif
@@ -204,5 +180,4 @@ %endif - <%include file="../video_modal.html" /> diff --git a/lms/templates/courseware/course_navigation.html b/lms/templates/courseware/course_navigation.html index b41aaedc70..ea367873e2 100644 --- a/lms/templates/courseware/course_navigation.html +++ b/lms/templates/courseware/course_navigation.html @@ -27,6 +27,42 @@ def url_class(is_active): % endfor <%block name="extratabs" /> + % if masquerade is not UNDEFINED: + % if staff_access and masquerade is not None: +
  • Staff view
  • + % endif + % endif + +% if masquerade is not UNDEFINED: + % if staff_access and masquerade is not None: + + % endif +% endif diff --git a/lms/templates/courseware/courseware.html b/lms/templates/courseware/courseware.html index 33dc9562a7..4889da25ca 100644 --- a/lms/templates/courseware/courseware.html +++ b/lms/templates/courseware/courseware.html @@ -156,7 +156,7 @@
    - +
    Hints @@ -176,8 +176,8 @@
    - - + +
    diff --git a/lms/templates/courseware/gradebook.html b/lms/templates/courseware/gradebook.html index fb750aed19..015004ee1c 100644 --- a/lms/templates/courseware/gradebook.html +++ b/lms/templates/courseware/gradebook.html @@ -13,9 +13,12 @@ <%static:css group='course'/> @@ -78,8 +81,8 @@ letter_grade = 'None' if fraction > 0: letter_grade = 'F' - for grade in ['A', 'B', 'C']: - if fraction >= course.grade_cutoffs[grade]: + for (grade, cutoff) in ordered_grades: + if fraction >= cutoff: letter_grade = grade break @@ -90,11 +93,11 @@ %for student in students: - + %for section in student['grade_summary']['section_breakdown']: ${percent_data( section['percent'] )} %endfor - ${percent_data( student['grade_summary']['percent'])} + ${percent_data( student['grade_summary']['percent'])} %endfor diff --git a/lms/templates/courseware/mktg_coming_soon.html b/lms/templates/courseware/mktg_coming_soon.html new file mode 100644 index 0000000000..c100c1cb5d --- /dev/null +++ b/lms/templates/courseware/mktg_coming_soon.html @@ -0,0 +1,30 @@ +<%! + from django.core.urlresolvers import reverse + from courseware.courses import course_image_url, get_course_about_section + from courseware.access import has_access +%> +<%namespace name='static' file='../static_content.html'/> + +<%inherit file="../mktg_iframe.html" /> + +<%block name="title">About ${course_id} + +<%block name="bodyclass">view-partial-mktgregister + + +<%block name="headextra"> + <%include file="../google_analytics.html" /> + + +<%block name="content"> + + + + + + + diff --git a/lms/templates/courseware/mktg_course_about.html b/lms/templates/courseware/mktg_course_about.html new file mode 100644 index 0000000000..dc667c850c --- /dev/null +++ b/lms/templates/courseware/mktg_course_about.html @@ -0,0 +1,75 @@ +<%! + from django.core.urlresolvers import reverse + from courseware.courses import course_image_url, get_course_about_section + from courseware.access import has_access +%> +<%namespace name='static' file='../static_content.html'/> + +<%inherit file="../mktg_iframe.html" /> + +<%block name="title">About ${course.number} + +<%block name="bodyclass">view-partial-mktgregister + + +<%block name="headextra"> + <%include file="../google_analytics.html" /> + + +<%block name="js_extra"> + + + +<%block name="content"> + + +
      +
    • + %if user.is_authenticated() and registered: + %if show_courseware_link: + Access Courseware + %else: +
      You Are Registered
      + %endif + %elif allow_registration: + Register for ${course.number} + %else: +
      Registration Is Closed
      + %endif +
    • +
    + +%if not registered: +
    +
    +
    + + + +
    +
    + +
    +
    +
    +%endif + diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html index d23609801f..75c0cafabd 100644 --- a/lms/templates/dashboard.html +++ b/lms/templates/dashboard.html @@ -59,14 +59,16 @@ $("#unenroll_course_number").text( $(event.target).data("course-number") ); }); - $(document).delegate('#unenroll_form', 'ajax:success', function(data, json, xhr) { - if(json.success) { - location.href="${reverse('dashboard')}"; + $('#unenroll_form').on('ajax:complete', function(event, xhr) { + if(xhr.status == 200) { + location.href = "${reverse('dashboard')}"; + } else if (xhr.status == 403) { + location.href = "${reverse('signin_user')}?course_id=" + + $("#unenroll_course_id").val() + "&enrollment_action=unenroll"; } else { - if($('#unenroll_error').length == 0) { - $('#unenroll_form').prepend(''); - } - $('#unenroll_error').text(json.error).stop().css("display", "block"); + $('#unenroll_error').html( + xhr.responseText ? xhr.responseText : "An error occurred. Please try again later." + ).stop().css("display", "block"); } }); @@ -192,17 +194,20 @@
    <% - if has_access(user, course, 'load'): course_target = reverse('info', args=[course.id]) - else: - course_target = reverse('about_course', args=[course.id]) %> - - - + % if course.id in show_courseware_links_for: + + + + % else: +
    + +
    + % endif
    @@ -216,7 +221,13 @@ % endif

    ${get_course_about_section(course, 'university')}

    -

    ${course.number} ${course.display_name_with_default}

    +

    + % if course.id in show_courseware_links_for: + ${course.number} ${course.display_name_with_default} + % else: + ${course.number} ${course.display_name_with_default} + % endif +

    <% @@ -326,7 +337,9 @@ % else:

    Looks like you haven't registered for any courses yet.

    - Find courses now! + + Find courses now! +
    % endif @@ -355,6 +368,8 @@
    + +
    diff --git a/lms/templates/debug/run_python_form.html b/lms/templates/debug/run_python_form.html new file mode 100644 index 0000000000..daecdf2abd --- /dev/null +++ b/lms/templates/debug/run_python_form.html @@ -0,0 +1,19 @@ + +
    +

    Python:

    + + +
    + +
    + + +
    +%if results: +
    +

    Results:

    +
    +${results|h}
    +
    +
    +%endif diff --git a/lms/templates/discussion/_filter_dropdown.html b/lms/templates/discussion/_filter_dropdown.html index 1f59d4235d..a191e1b583 100644 --- a/lms/templates/discussion/_filter_dropdown.html +++ b/lms/templates/discussion/_filter_dropdown.html @@ -33,6 +33,14 @@ Show All Discussions + %if flag_moderator: +
  • + + Show Flagged Discussions + +
  • + + %endif
  • Following diff --git a/lms/templates/discussion/_single_thread.html b/lms/templates/discussion/_single_thread.html index 0dec32ad47..e291bc955c 100644 --- a/lms/templates/discussion/_single_thread.html +++ b/lms/templates/discussion/_single_thread.html @@ -6,7 +6,7 @@
    - %if thread['group_id'] + %if thread['group_id']:
    This post visible only to group ${cohort_dictionary[thread['group_id']]}.
    %endif @@ -35,4 +35,4 @@
  • -<%include file="_js_data.html" /> \ No newline at end of file +<%include file="_js_data.html" /> diff --git a/lms/templates/discussion/_underscore_templates.html b/lms/templates/discussion/_underscore_templates.html index 24e3b467be..fcbcf1a52c 100644 --- a/lms/templates/discussion/_underscore_templates.html +++ b/lms/templates/discussion/_underscore_templates.html @@ -3,6 +3,7 @@ diff --git a/lms/templates/help_modal.html b/lms/templates/help_modal.html new file mode 100644 index 0000000000..350f858334 --- /dev/null +++ b/lms/templates/help_modal.html @@ -0,0 +1,175 @@ +<%namespace name='static' file='static_content.html'/> +<%! from datetime import datetime %> +<%! import pytz %> +<%! from django.conf import settings %> +<%! from courseware.tabs import get_discussion_link %> + +% if settings.MITX_FEATURES.get('ENABLE_FEEDBACK_SUBMISSION', False): + + + + + + + +%endif diff --git a/lms/templates/index.html b/lms/templates/index.html index f2fc34c8ec..109ab6fb2f 100644 --- a/lms/templates/index.html +++ b/lms/templates/index.html @@ -190,18 +190,11 @@ diff --git a/lms/templates/login.html b/lms/templates/login.html new file mode 100644 index 0000000000..3e33c84b7a --- /dev/null +++ b/lms/templates/login.html @@ -0,0 +1,175 @@ +<%inherit file="main.html" /> + +<%namespace name='static' file='static_content.html'/> +<%! from django.core.urlresolvers import reverse %> +<%block name="title">Log into your edX Account + +<%block name="js_extra"> + + + +
    +
    +

    Log Into Your Account

    +
    +
    + + + diff --git a/lms/templates/login_modal.html b/lms/templates/login_modal.html index 1587cca767..de1c437caf 100644 --- a/lms/templates/login_modal.html +++ b/lms/templates/login_modal.html @@ -9,14 +9,17 @@