Merge branch 'master' into feature/kevin/email_notifications_panel
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -45,3 +45,4 @@ node_modules
|
||||
autodeploy.properties
|
||||
.ws_migrations_complete
|
||||
.vagrant/
|
||||
logs
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
[main]
|
||||
host = https://www.transifex.com
|
||||
|
||||
[edx-studio.django-partial]
|
||||
[edx-platform.django-partial]
|
||||
file_filter = conf/locale/<lang>/LC_MESSAGES/django-partial.po
|
||||
source_file = conf/locale/en/LC_MESSAGES/django-partial.po
|
||||
source_lang = en
|
||||
type = PO
|
||||
|
||||
[edx-studio.djangojs]
|
||||
[edx-platform.djangojs]
|
||||
file_filter = conf/locale/<lang>/LC_MESSAGES/djangojs.po
|
||||
source_file = conf/locale/en/LC_MESSAGES/djangojs.po
|
||||
source_lang = en
|
||||
type = PO
|
||||
|
||||
[edx-studio.mako]
|
||||
[edx-platform.mako]
|
||||
file_filter = conf/locale/<lang>/LC_MESSAGES/mako.po
|
||||
source_file = conf/locale/en/LC_MESSAGES/mako.po
|
||||
source_lang = en
|
||||
type = PO
|
||||
|
||||
[edx-studio.messages]
|
||||
[edx-platform.messages]
|
||||
file_filter = conf/locale/<lang>/LC_MESSAGES/messages.po
|
||||
source_file = conf/locale/en/LC_MESSAGES/messages.po
|
||||
source_lang = en
|
||||
|
||||
3
AUTHORS
3
AUTHORS
@@ -81,3 +81,6 @@ Felix Sun <felixsun@mit.edu>
|
||||
Adam Palay <adam@edx.org>
|
||||
Ian Hoover <ihoover@edx.org>
|
||||
Mukul Goyal <miki@edx.org>
|
||||
Robert Marks <rmarks@edx.org>
|
||||
Yarko Tymciurak <yarkot1@gmail.com>
|
||||
|
||||
|
||||
@@ -5,10 +5,23 @@ These are notable changes in edx-platform. This is a rolling list of changes,
|
||||
in roughly chronological order, most recent first. Add your entries at or near
|
||||
the top. Include a label indicating the component affected.
|
||||
|
||||
LMS: Added user preferences (arbitrary user/key/value tuples, for which
|
||||
which user/key is unique) and a REST API for reading users and
|
||||
preferences. Access to the REST API is restricted by use of the
|
||||
X-Edx-Api-Key HTTP header (which must match settings.EDX_API_KEY; if
|
||||
the setting is not present, the API is disabled).
|
||||
|
||||
LMS: Added endpoints for AJAX requests to enable/disable notifications
|
||||
(which are not yet implemented) and a one-click unsubscribe page.
|
||||
|
||||
Common: Add a manage.py that knows about edx-platform specific settings and projects
|
||||
|
||||
Common: Added *experimental* support for jsinput type.
|
||||
|
||||
Common: Added setting to specify Celery Broker vhost
|
||||
|
||||
Common: Utilize new XBlock bulk save API in LMS and CMS.
|
||||
|
||||
Studio: Add table for tracking course creator permissions (not yet used).
|
||||
Update rake django-admin[syncdb] and rake django-admin[migrate] so they
|
||||
run for both LMS and CMS.
|
||||
@@ -21,6 +34,8 @@ Studio: Added support for uploading and managing PDF textbooks
|
||||
|
||||
Common: Student information is now passed to the tracking log via POST instead of GET.
|
||||
|
||||
Blades: Added functionality and tests for new capa input type: choicetextresponse.
|
||||
|
||||
Common: Add tests for documentation generation to test suite
|
||||
|
||||
Blades: User answer now preserved (and changeable) after clicking "show answer" in choice problems
|
||||
@@ -43,6 +58,13 @@ history of background tasks for a given problem and student.
|
||||
Blades: Small UX fix on capa multiple-choice problems. Make labels only
|
||||
as wide as the text to reduce accidental choice selections.
|
||||
|
||||
Studio:
|
||||
- use xblock field defaults to initialize all new instances' fields and
|
||||
only use templates as override samples.
|
||||
- create new instances via in memory create_xmodule and related methods rather
|
||||
than cloning a db record.
|
||||
- have an explicit method for making a draft copy as distinct from making a new module.
|
||||
|
||||
Studio: Remove XML from the video component editor. All settings are
|
||||
moved to be edited as metadata.
|
||||
|
||||
|
||||
170
README.md
170
README.md
@@ -12,30 +12,35 @@ installation process.
|
||||
|
||||
1. Make sure you have plenty of available disk space, >5GB
|
||||
2. Install Git: http://git-scm.com/downloads
|
||||
3. Install VirtualBox: https://www.virtualbox.org/wiki/Download_Old_Builds_4_2
|
||||
(you need version 4.2.12, as later/earlier versions might not work well with
|
||||
Vagrant)
|
||||
3. Install VirtualBox: https://www.virtualbox.org/wiki/Downloads
|
||||
See http://docs.vagrantup.com/v2/providers/index.html for a list of supported
|
||||
Providers. You should use VirtualBox >= 4.2.12.
|
||||
(Windows: later/earlier VirtualBox versions than 4.2.12 have been reported to not work well with
|
||||
Vagrant. If this is still a problem, you can
|
||||
install 4.2.12 from https://www.virtualbox.org/wiki/Download_Old_Builds_4_2).
|
||||
4. Install Vagrant: http://www.vagrantup.com/ (Vagrant 1.2.2 or later)
|
||||
5. Open a terminal
|
||||
6. Download the project: `git clone git://github.com/edx/edx-platform.git`
|
||||
7. Enter the project directory: `cd edx-platform/`
|
||||
8. (Windows only) Run the commands to
|
||||
8. (Windows only) Run the commands to
|
||||
[deal with line endings and symlinks under Windows](https://github.com/edx/edx-platform/wiki/Simplified-install-with-vagrant#dealing-with-line-endings-and-symlinks-under-windows)
|
||||
9. Start: `vagrant up`
|
||||
9. Create the development environment and start it: `vagrant up`
|
||||
|
||||
The last step might require your host machine's administrator password to setup NFS.
|
||||
The initial `vagrant up` will download a Linux image, then boot and ask for your
|
||||
host machine's administrator password to setup file sharing between your computer and the VM.
|
||||
Once file sharing is established, `edx-platform/scripts/create-dev-env.sh` will
|
||||
install dependencies and configure the VM.
|
||||
This will take a while; go grab a coffee.
|
||||
|
||||
Afterwards, it will download an image, install all the dependencies and configure
|
||||
the VM. It will take a while, go grab a coffee.
|
||||
When complete, you should see a _"Success!"_ message.
|
||||
If not, refer to the
|
||||
[troubleshooting section](https://github.com/edx/edx-platform/wiki/Simplified-install-with-vagrant#troubleshooting).
|
||||
|
||||
Once completed, hopefully you should see a "Success!" message indicating that the
|
||||
installation went fine. (If not, refer to the
|
||||
[troubleshooting section](https://github.com/edx/edx-platform/wiki/Simplified-install-with-vagrant#troubleshooting).)
|
||||
Your development environment is initialized only on the first bring-up.
|
||||
Subsequently `vagrant up` commands will boot your virtual machine normally.
|
||||
|
||||
Note: by default, the VM will get the IP `192.168.20.40`. If you need to use a
|
||||
different IP, you can edit the file `Vagrantfile`. If you have already started the
|
||||
VM with `vagrant up`, see "Stopping and restarting the VM" below to take the change
|
||||
into account.
|
||||
Note: by default, the VM will get the IP `192.168.20.40`.
|
||||
You can change this in your `Vagrantfile` (the startup message will reflect your VM's actual IP).
|
||||
|
||||
Accessing the VM
|
||||
----------------
|
||||
@@ -46,15 +51,24 @@ Once the installation is finished, to log into the virtual machine:
|
||||
$ vagrant ssh
|
||||
```
|
||||
|
||||
Note: This won't work from Windows, install install PuTTY from
|
||||
http://www.chiark.greenend.org.uk/%7Esgtatham/putty/download.html instead. Then
|
||||
connect to 127.0.0.1, port 2222, using vagrant/vagrant as a user/password.
|
||||
Note: This won't work from Windows. Instead, install PuTTY from
|
||||
http://www.chiark.greenend.org.uk/%7Esgtatham/putty/download.html. Then
|
||||
connect to 192.168.20.40, port 2222, using vagrant/vagrant as a user/password.
|
||||
|
||||
|
||||
Using edX
|
||||
---------
|
||||
|
||||
Once inside the VM, you can start Studio and LMS with the following commands
|
||||
(from the `/opt/edx/edx-platform` folder):
|
||||
When you login to your VM, you are in
|
||||
`/opt/edx/edx-platform` by default, which is shared from your host workspace.
|
||||
Your host computer contains the edx-project development code and repository.
|
||||
Your VM runs edx-platform code mounted from your host, so
|
||||
you can develop by editing on your host.
|
||||
|
||||
After logging into your VM with `vagrant ssh`,
|
||||
start the _Studio_ and
|
||||
_Learning management system (LMS)_
|
||||
servers (run these from `/opt/edx/edx-platform`):
|
||||
|
||||
Learning management system (LMS):
|
||||
|
||||
@@ -62,46 +76,85 @@ Learning management system (LMS):
|
||||
$ rake lms[cms.dev,0.0.0.0:8000]
|
||||
```
|
||||
|
||||
Studio:
|
||||
Studio (CMS):
|
||||
|
||||
```
|
||||
$ rake cms[dev,0.0.0.0:8001]
|
||||
```
|
||||
|
||||
Once started, open the following URLs in your browser:
|
||||
The servers will come up to these URLs:
|
||||
|
||||
* Learning management system (LMS): http://192.168.20.40:8000/
|
||||
* Studio (CMS): http://192.168.20.40:8001/
|
||||
- LMS: http://192.168.20.40:8000/
|
||||
- CMS: http://192.168.20.40:8001/
|
||||
|
||||
You can develop by editing the files directly in the `edx-platform/` directory you
|
||||
downloaded before, you don't need to connect to the VM to edit them (the VM uses
|
||||
those files to run edX, mirroring the folder in `/opt/edx/edx-platform`).
|
||||
Your VM's port 8000 is forwarded to host port 9000
|
||||
so you can also access the LMS with [http://localhost:9000/]().
|
||||
Similarly, VM port 8001 is forwarded to host port 9001.
|
||||
These are set in your `Vagrantfile`.
|
||||
|
||||
You may also want to create a super-user with:
|
||||
|
||||
```
|
||||
$ rake django-admin["createsuperuser"]
|
||||
```
|
||||
|
||||
Also note that if you register a new user through the web interface,
|
||||
the activiation email will be posted to your VM's terminal window (search for
|
||||
lines similar to):
|
||||
Note that when you register a new user through the web interface,
|
||||
by default the activiation email will be appear on your VM's terminal.
|
||||
Search for lines similar to:
|
||||
|
||||
```
|
||||
Subject: Your account for edX Studio
|
||||
From: registration@edx.org
|
||||
```
|
||||
|
||||
and find the activation URL for the account you've created.
|
||||
and find the activation URL.
|
||||
|
||||
See the [Frequently Asked Questions](https://github.com/edx/edx-platform/wiki/Frequently-Asked-Questions)
|
||||
for more usage tips.
|
||||
|
||||
Django admin & debug toolbar
|
||||
-----------------------------
|
||||
|
||||
You can enable admin logins and the debug_toolbar by editing
|
||||
`lms/envs/common.py`:
|
||||
|
||||
- enable ADMIN login page by setting:
|
||||
- ```
|
||||
'ENABLE_DJANGO_ADMIN_SITE': True
|
||||
```
|
||||
|
||||
|
||||
- enable debug toolbar by uncommenting:
|
||||
- ```
|
||||
# 'debug_toolbar.middleware.DebugToolbarMiddleware',
|
||||
```
|
||||
|
||||
These are also defined in `lms/envs/dev.py`,
|
||||
and usually active on localhost.
|
||||
|
||||
To get at your VM's 127.0.0.1, explicitly forward one of VM's available localhost ports to your computer.
|
||||
Instead of `vagrant ssh`, login with:
|
||||
|
||||
```
|
||||
$ ssh -L 6080:127.0.0.1:8080 vagrant@192.168.20.40
|
||||
```
|
||||
|
||||
The password is _vagrant_.
|
||||
|
||||
From your VM, start the LMS as a localhost instance:
|
||||
|
||||
```
|
||||
$ rake lms[cms.dev,127.0.0.1:8080]
|
||||
```
|
||||
|
||||
You should see the debug toolbar now on [http:/localhost:6080/]().
|
||||
You should now also see a login on [http://localhost:6080/admin/]()
|
||||
You will need a privileged user for the admin login.
|
||||
You can create a CMS/LMS super-user with:
|
||||
```
|
||||
$ ./manage.py lms createsuperuser
|
||||
```
|
||||
|
||||
|
||||
Stopping & starting
|
||||
-------------------
|
||||
|
||||
To stop the VM (from your `edx-platform/` directory):
|
||||
|
||||
To stop the VM (from your `edx-platform/` directory):
|
||||
```
|
||||
$ vagrant halt
|
||||
```
|
||||
@@ -112,16 +165,27 @@ To restart:
|
||||
$ vagrant up
|
||||
```
|
||||
|
||||
or, to start without attempting to update the dependencies:
|
||||
To suspend and resume tasks in progress on your VM:
|
||||
```
|
||||
$ vagrant suspend
|
||||
$ # and later...
|
||||
$ vagrant resume
|
||||
```
|
||||
|
||||
Your development environment is normally created once, on first `vagrant up`.
|
||||
You can continue to fetch changes in edx-platform
|
||||
as you work with your VM.
|
||||
To re-create your VM and create a fresh development environment:
|
||||
```
|
||||
$ vagrant up --no-provision
|
||||
$ vagrant destroy
|
||||
$ vagrant up # will make a new VM
|
||||
```
|
||||
|
||||
|
||||
Troubleshooting
|
||||
---------------
|
||||
|
||||
If anything doesn't work as expected, see the
|
||||
If anything doesn't work as expected, see the
|
||||
[troubleshooting section](https://github.com/edx/edx-platform/wiki/Simplified-install-with-vagrant#troubleshooting).
|
||||
|
||||
Installation - Advanced
|
||||
@@ -229,24 +293,12 @@ 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:
|
||||
tables in that database, and run database migrations. Fortunately, `django`
|
||||
will do all of this for you
|
||||
|
||||
$ rake django-admin[syncdb]
|
||||
$ rake django-admin[migrate]
|
||||
$ rake cms: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_%28programming%29), 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]"`.
|
||||
$ ./manage.py lms syncdb --migrate
|
||||
$ ./manage.py cms syncdb --migrate
|
||||
|
||||
Run Your Project
|
||||
----------------
|
||||
@@ -254,6 +306,10 @@ 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.
|
||||
|
||||
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.
|
||||
|
||||
To run Studio, run:
|
||||
|
||||
$ rake cms
|
||||
|
||||
@@ -20,8 +20,8 @@ def get_course_updates(location):
|
||||
try:
|
||||
course_updates = modulestore('direct').get_item(location)
|
||||
except ItemNotFoundError:
|
||||
template = Location(['i4x', 'edx', "templates", 'course_info', "Empty"])
|
||||
course_updates = modulestore('direct').clone_item(template, Location(location))
|
||||
modulestore('direct').create_and_save_xmodule(location)
|
||||
course_updates = modulestore('direct').get_item(location)
|
||||
|
||||
# current db rep: {"_id" : locationjson, "definition" : { "data" : "<ol>[<li><h2>date</h2>content</li>]</ol>"} "metadata" : ignored}
|
||||
location_base = course_updates.location.url()
|
||||
|
||||
@@ -8,7 +8,7 @@ Feature: Course checklists
|
||||
Scenario: A course author can mark tasks as complete
|
||||
Given I have opened Checklists
|
||||
Then I can check and uncheck tasks in a checklist
|
||||
And They are correctly selected after I reload the page
|
||||
And They are correctly selected after reloading the page
|
||||
|
||||
Scenario: A task can link to a location within Studio
|
||||
Given I have opened Checklists
|
||||
|
||||
@@ -45,7 +45,7 @@ def i_can_check_and_uncheck_tasks(step):
|
||||
verifyChecklist2Status(2, 7, 29)
|
||||
|
||||
|
||||
@step('They are correctly selected after I reload the page$')
|
||||
@step('They are correctly selected after reloading the page$')
|
||||
def tasks_correctly_selected_after_reload(step):
|
||||
reload_the_page(step)
|
||||
verifyChecklist2Status(2, 7, 29)
|
||||
|
||||
@@ -208,8 +208,9 @@ def set_date_and_time(date_css, desired_date, time_css, desired_time):
|
||||
def i_created_a_video_component(step):
|
||||
world.create_component_instance(
|
||||
step, '.large-video-icon',
|
||||
'i4x://edx/templates/video/default',
|
||||
'.xmodule_VideoModule'
|
||||
'video',
|
||||
'.xmodule_VideoModule',
|
||||
has_multiple_templates=False
|
||||
)
|
||||
|
||||
|
||||
@@ -238,6 +239,17 @@ def save_button_disabled(step):
|
||||
assert world.css_has_class(button_css, disabled)
|
||||
|
||||
|
||||
@step('I confirm the prompt')
|
||||
def confirm_the_prompt(step):
|
||||
prompt_css = 'a.button.action-primary'
|
||||
world.css_click(prompt_css)
|
||||
|
||||
|
||||
@step(u'I am shown a (.*)$')
|
||||
def i_am_shown_a_notification(step, notification_type):
|
||||
assert world.is_css_present('.wrapper-%s' % notification_type)
|
||||
|
||||
|
||||
def type_in_codemirror(index, text):
|
||||
world.css_click(".CodeMirror", index=index)
|
||||
g = world.css_find("div.CodeMirror.CodeMirror-focused > div > textarea")
|
||||
|
||||
@@ -67,3 +67,21 @@ Feature: Component Adding
|
||||
When I will confirm all alerts
|
||||
And I delete all components
|
||||
Then I see no components
|
||||
|
||||
Scenario: I see a prompt on delete
|
||||
Given I have opened a new course in studio
|
||||
And I am editing a new unit
|
||||
And I add the following components:
|
||||
| Component |
|
||||
| Discussion |
|
||||
And I delete a component
|
||||
Then I am shown a prompt
|
||||
|
||||
Scenario: I see a notification on save
|
||||
Given I have opened a new course in studio
|
||||
And I am editing a new unit
|
||||
And I add the following components:
|
||||
| Component |
|
||||
| Discussion |
|
||||
And I edit and save a component
|
||||
Then I am shown a notification
|
||||
|
||||
@@ -41,6 +41,17 @@ def see_no_components(steps):
|
||||
assert world.is_css_not_present('li.component')
|
||||
|
||||
|
||||
@step(u'I delete a component')
|
||||
def delete_one_component(step):
|
||||
world.css_click('a.delete-button')
|
||||
|
||||
|
||||
@step(u'I edit and save a component')
|
||||
def edit_and_save_component(step):
|
||||
world.css_click('.edit-button')
|
||||
world.css_click('.save-button')
|
||||
|
||||
|
||||
def step_selector_list(data_type, path, index=1):
|
||||
selector_list = ['a[data-type="{}"]'.format(data_type)]
|
||||
if index != 1:
|
||||
|
||||
@@ -7,10 +7,16 @@ 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)
|
||||
def create_component_instance(step, component_button_css, category,
|
||||
expected_css, boilerplate=None,
|
||||
has_multiple_templates=True):
|
||||
|
||||
click_new_component_button(step, component_button_css)
|
||||
|
||||
if has_multiple_templates:
|
||||
click_component_from_menu(category, boilerplate, expected_css)
|
||||
|
||||
assert_equal(1, len(world.css_find(expected_css)))
|
||||
|
||||
@world.absorb
|
||||
def click_new_component_button(step, component_button_css):
|
||||
@@ -19,7 +25,7 @@ def click_new_component_button(step, component_button_css):
|
||||
|
||||
|
||||
@world.absorb
|
||||
def click_component_from_menu(instance_id, expected_css):
|
||||
def click_component_from_menu(category, boilerplate, expected_css):
|
||||
"""
|
||||
Creates a component from `instance_id`. For components with more
|
||||
than one template, clicks on `elem_css` to create the new
|
||||
@@ -27,12 +33,13 @@ def click_component_from_menu(instance_id, expected_css):
|
||||
as the user clicks the appropriate button, so we assert that the
|
||||
expected component is present.
|
||||
"""
|
||||
elem_css = "a[data-location='%s']" % instance_id
|
||||
if boilerplate:
|
||||
elem_css = "a[data-category='{}'][data-boilerplate='{}']".format(category, boilerplate)
|
||||
else:
|
||||
elem_css = "a[data-category='{}']:not([data-boilerplate])".format(category)
|
||||
elements = world.css_find(elem_css)
|
||||
assert(len(elements) == 1)
|
||||
if elements[0]['id'] == instance_id: # If this is a component with multiple templates
|
||||
world.css_click(elem_css)
|
||||
assert_equal(1, len(world.css_find(expected_css)))
|
||||
assert_equal(len(elements), 1)
|
||||
world.css_click(elem_css)
|
||||
|
||||
|
||||
@world.absorb
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
Feature: Overview Toggle Section
|
||||
In order to quickly view the details of a course's section or to scan the inventory of sections
|
||||
Feature: Course Overview
|
||||
In order to quickly view the details of a course's section and set release dates and grading
|
||||
As a course author
|
||||
I want to toggle the visibility of each section's subsection details in the overview listing
|
||||
I want to use the course overview page
|
||||
|
||||
Scenario: The default layout for the overview page is to show sections in expanded view
|
||||
Given I have a course with multiple sections
|
||||
@@ -57,3 +57,9 @@ Feature: Overview Toggle Section
|
||||
And I click the "Expand All Sections" link
|
||||
Then I see the "Collapse All Sections" link
|
||||
And all sections are expanded
|
||||
|
||||
Scenario: Notification is shown on grading status changes
|
||||
Given I have a course with 1 section
|
||||
When I navigate to the course overview page
|
||||
And I change an assignment's grading status
|
||||
Then I am shown a notification
|
||||
@@ -22,7 +22,7 @@ def have_a_course_with_1_section(step):
|
||||
section = world.ItemFactory.create(parent_location=course.location)
|
||||
subsection1 = world.ItemFactory.create(
|
||||
parent_location=section.location,
|
||||
template='i4x://edx/templates/sequential/Empty',
|
||||
category='sequential',
|
||||
display_name='Subsection One',)
|
||||
|
||||
|
||||
@@ -33,18 +33,18 @@ def have_a_course_with_two_sections(step):
|
||||
section = world.ItemFactory.create(parent_location=course.location)
|
||||
subsection1 = world.ItemFactory.create(
|
||||
parent_location=section.location,
|
||||
template='i4x://edx/templates/sequential/Empty',
|
||||
category='sequential',
|
||||
display_name='Subsection One',)
|
||||
section2 = world.ItemFactory.create(
|
||||
parent_location=course.location,
|
||||
display_name='Section Two',)
|
||||
subsection2 = world.ItemFactory.create(
|
||||
parent_location=section2.location,
|
||||
template='i4x://edx/templates/sequential/Empty',
|
||||
category='sequential',
|
||||
display_name='Subsection Alpha',)
|
||||
subsection3 = world.ItemFactory.create(
|
||||
parent_location=section2.location,
|
||||
template='i4x://edx/templates/sequential/Empty',
|
||||
category='sequential',
|
||||
display_name='Subsection Beta',)
|
||||
|
||||
|
||||
@@ -118,3 +118,9 @@ def all_sections_are_collapsed(step):
|
||||
subsections = world.css_find(subsection_locator)
|
||||
for index in range(len(subsections)):
|
||||
assert_false(world.css_visible(subsection_locator, index=index))
|
||||
|
||||
|
||||
@step(u"I change an assignment's grading status")
|
||||
def change_grading_status(step):
|
||||
world.css_find('a.menu-toggle').click()
|
||||
world.css_find('.menu li').first.click()
|
||||
@@ -8,8 +8,9 @@ from lettuce import world, step
|
||||
def i_created_discussion_tag(step):
|
||||
world.create_component_instance(
|
||||
step, '.large-discussion-icon',
|
||||
'i4x://edx/templates/discussion/Discussion_Tag',
|
||||
'.xmodule_DiscussionModule'
|
||||
'discussion',
|
||||
'.xmodule_DiscussionModule',
|
||||
has_multiple_templates=False
|
||||
)
|
||||
|
||||
|
||||
@@ -17,14 +18,14 @@ def i_created_discussion_tag(step):
|
||||
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]
|
||||
['Category', "Week 1", False],
|
||||
['Display Name', "Discussion Tag", False],
|
||||
['Subcategory', "Topic-Level Student-Visible Label", False]
|
||||
])
|
||||
|
||||
|
||||
@step('creating a discussion takes a single click')
|
||||
def discussion_takes_a_single_click(step):
|
||||
assert(not world.is_css_present('.xmodule_DiscussionModule'))
|
||||
world.css_click("a[data-location='i4x://edx/templates/discussion/Discussion_Tag']")
|
||||
world.css_click("a[data-category='discussion']")
|
||||
assert(world.is_css_present('.xmodule_DiscussionModule'))
|
||||
|
||||
@@ -7,11 +7,11 @@ 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',
|
||||
step, '.large-html-icon', 'html',
|
||||
'.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]])
|
||||
world.verify_all_setting_entries([['Display Name', "Blank HTML Page", False]])
|
||||
|
||||
@@ -18,8 +18,9 @@ def i_created_blank_common_problem(step):
|
||||
world.create_component_instance(
|
||||
step,
|
||||
'.large-problem-icon',
|
||||
'i4x://edx/templates/problem/Blank_Common_Problem',
|
||||
'.xmodule_CapaModule'
|
||||
'problem',
|
||||
'.xmodule_CapaModule',
|
||||
'blank_common.yaml'
|
||||
)
|
||||
|
||||
|
||||
@@ -35,8 +36,8 @@ def i_see_five_settings_with_values(step):
|
||||
[DISPLAY_NAME, "Blank Common Problem", True],
|
||||
[MAXIMUM_ATTEMPTS, "", False],
|
||||
[PROBLEM_WEIGHT, "", False],
|
||||
[RANDOMIZATION, "Never", True],
|
||||
[SHOW_ANSWER, "Finished", True]
|
||||
[RANDOMIZATION, "Never", False],
|
||||
[SHOW_ANSWER, "Finished", False]
|
||||
])
|
||||
|
||||
|
||||
@@ -94,7 +95,7 @@ def my_change_to_randomization_is_persisted(step):
|
||||
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)
|
||||
world.verify_setting_entry(world.get_setting_entry(RANDOMIZATION), RANDOMIZATION, "Never", False)
|
||||
|
||||
|
||||
@step('I can set the weight to "(.*)"?')
|
||||
@@ -156,7 +157,7 @@ def create_latex_problem(step):
|
||||
world.click_new_component_button(step, '.large-problem-icon')
|
||||
# Go to advanced tab.
|
||||
world.css_click('#ui-id-2')
|
||||
world.click_component_from_menu("i4x://edx/templates/problem/Problem_Written_in_LaTeX", '.xmodule_CapaModule')
|
||||
world.click_component_from_menu("problem", "latex_problem.yaml", '.xmodule_CapaModule')
|
||||
|
||||
|
||||
@step('I edit and compile the High Level Source')
|
||||
@@ -169,7 +170,8 @@ def edit_latex_source(step):
|
||||
@step('my change to the High Level Source is persisted')
|
||||
def high_level_source_persisted(step):
|
||||
def verify_text(driver):
|
||||
return world.css_text('.problem') == 'hi'
|
||||
css_sel = '.problem div>span'
|
||||
return world.css_text(css_sel) == 'hi'
|
||||
|
||||
world.wait_for(verify_text)
|
||||
|
||||
@@ -203,7 +205,7 @@ def verify_modified_display_name_with_special_chars():
|
||||
|
||||
|
||||
def verify_unset_display_name():
|
||||
world.verify_setting_entry(world.get_setting_entry(DISPLAY_NAME), DISPLAY_NAME, '', False)
|
||||
world.verify_setting_entry(world.get_setting_entry(DISPLAY_NAME), DISPLAY_NAME, 'Blank Advanced Problem', False)
|
||||
|
||||
|
||||
def set_weight(weight):
|
||||
|
||||
@@ -33,4 +33,5 @@ Feature: Create Section
|
||||
And I have added a new section
|
||||
When I will confirm all alerts
|
||||
And I press the "section" delete icon
|
||||
And I confirm the prompt
|
||||
Then the section does not exist
|
||||
|
||||
@@ -38,4 +38,5 @@ Feature: Create Subsection
|
||||
And I see my subsection on the Courseware page
|
||||
When I will confirm all alerts
|
||||
And I press the "subsection" delete icon
|
||||
And I confirm the prompt
|
||||
Then the subsection does not exist
|
||||
|
||||
@@ -7,7 +7,7 @@ from lettuce import world, step
|
||||
@step('I see the correct settings and default values$')
|
||||
def i_see_the_correct_settings_and_values(step):
|
||||
world.verify_all_setting_entries([['Default Speed', 'OEoXaMPEzfM', False],
|
||||
['Display Name', 'default', True],
|
||||
['Display Name', 'Video Title', False],
|
||||
['Download Track', '', False],
|
||||
['Download Video', '', False],
|
||||
['Show Captions', 'True', False],
|
||||
|
||||
@@ -14,7 +14,7 @@ def does_not_autoplay(_step):
|
||||
@step('creating a video takes a single click')
|
||||
def video_takes_a_single_click(_step):
|
||||
assert(not world.is_css_present('.xmodule_VideoModule'))
|
||||
world.css_click("a[data-location='i4x://edx/templates/video/default']")
|
||||
world.css_click("a[data-category='video']")
|
||||
assert(world.is_css_present('.xmodule_VideoModule'))
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from json import dumps
|
||||
from xmodule.modulestore.inheritance import own_metadata
|
||||
from django.conf import settings
|
||||
|
||||
filter_list = ['xml_attributes', 'checklists']
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = '''Write out to stdout a structural and metadata information about a course in a flat dictionary serialized
|
||||
in a JSON format. This can be used for analytics.'''
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if len(args) < 2 or len(args) > 3:
|
||||
raise CommandError("dump_course_structure requires two or more arguments: <location> <outfile> |<db>|")
|
||||
|
||||
course_id = args[0]
|
||||
outfile = args[1]
|
||||
|
||||
# use a user-specified database name, if present
|
||||
# this is useful for doing dumps from databases restored from prod backups
|
||||
if len(args) == 3:
|
||||
settings.MODULESTORE['direct']['OPTIONS']['db'] = args[2]
|
||||
|
||||
loc = CourseDescriptor.id_to_location(course_id)
|
||||
|
||||
store = modulestore()
|
||||
|
||||
course = None
|
||||
try:
|
||||
course = store.get_item(loc, depth=4)
|
||||
except:
|
||||
print 'Could not find course at {0}'.format(course_id)
|
||||
return
|
||||
|
||||
info = {}
|
||||
|
||||
def dump_into_dict(module, info):
|
||||
filtered_metadata = dict((key, value) for key, value in own_metadata(module).iteritems()
|
||||
if key not in filter_list)
|
||||
info[module.location.url()] = {
|
||||
'category': module.location.category,
|
||||
'children': module.children if hasattr(module, 'children') else [],
|
||||
'metadata': filtered_metadata
|
||||
}
|
||||
|
||||
for child in module.get_children():
|
||||
dump_into_dict(child, info)
|
||||
|
||||
dump_into_dict(course, info)
|
||||
|
||||
with open(outfile, 'w') as f:
|
||||
f.write(dumps(info))
|
||||
@@ -1,10 +0,0 @@
|
||||
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'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
update_templates(modulestore('direct'))
|
||||
@@ -3,13 +3,13 @@ from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from xmodule.modulestore import Location
|
||||
|
||||
|
||||
def get_module_info(store, location, parent_location=None, rewrite_static_links=False):
|
||||
def get_module_info(store, location, rewrite_static_links=False):
|
||||
try:
|
||||
module = store.get_item(location)
|
||||
except ItemNotFoundError:
|
||||
# create a new one
|
||||
template_location = Location(['i4x', 'edx', 'templates', location.category, 'Empty'])
|
||||
module = store.clone_item(template_location, location)
|
||||
store.create_and_save_xmodule(location)
|
||||
module = store.get_item(location)
|
||||
|
||||
data = module.data
|
||||
if rewrite_static_links:
|
||||
@@ -29,7 +29,8 @@ def get_module_info(store, location, parent_location=None, rewrite_static_links=
|
||||
'id': module.location.url(),
|
||||
'data': data,
|
||||
# TODO (cpennington): This really shouldn't have to do this much reaching in to get the metadata
|
||||
'metadata': module._model_data._kvs._metadata
|
||||
# what's the intent here? all metadata incl inherited & namespaced?
|
||||
'metadata': module.xblock_kvs._metadata
|
||||
}
|
||||
|
||||
|
||||
@@ -37,14 +38,11 @@ def set_module_info(store, location, post_data):
|
||||
module = None
|
||||
try:
|
||||
module = store.get_item(location)
|
||||
except:
|
||||
pass
|
||||
|
||||
if module is None:
|
||||
# new module at this location
|
||||
# presume that we have an 'Empty' template
|
||||
template_location = Location(['i4x', 'edx', 'templates', location.category, 'Empty'])
|
||||
module = store.clone_item(template_location, location)
|
||||
except ItemNotFoundError:
|
||||
# new module at this location: almost always used for the course about pages; thus, no parent. (there
|
||||
# are quite a handful of about page types available for a course and only the overview is pre-created)
|
||||
store.create_and_save_xmodule(location)
|
||||
module = store.get_item(location)
|
||||
|
||||
if post_data.get('data') is not None:
|
||||
data = post_data['data']
|
||||
@@ -79,4 +77,4 @@ def set_module_info(store, location, post_data):
|
||||
|
||||
# commit to datastore
|
||||
# TODO (cpennington): This really shouldn't have to do this much reaching in to get the metadata
|
||||
store.update_metadata(location, module._model_data._kvs._metadata)
|
||||
store.update_metadata(location, module.xblock_kvs._metadata)
|
||||
|
||||
@@ -46,6 +46,8 @@ class ChecklistTestCase(CourseTestCase):
|
||||
# Now delete the checklists from the course and verify they get repopulated (for courses
|
||||
# created before checklists were introduced).
|
||||
self.course.checklists = None
|
||||
# Save the changed `checklists` to the underlying KeyValueStore before updating the modulestore
|
||||
self.course.save()
|
||||
modulestore = get_modulestore(self.course.location)
|
||||
modulestore.update_metadata(self.course.location, own_metadata(self.course))
|
||||
self.assertEqual(self.get_persisted_checklists(), None)
|
||||
|
||||
@@ -24,12 +24,11 @@ from auth.authz import add_user_to_creator_group
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
|
||||
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore import Location, mongo
|
||||
from xmodule.modulestore.store_utilities import clone_course
|
||||
from xmodule.modulestore.store_utilities import delete_course
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.contentstore.django import contentstore, _CONTENTSTORE
|
||||
from xmodule.templates import update_templates
|
||||
from xmodule.modulestore.xml_exporter import export_to_xml
|
||||
from xmodule.modulestore.xml_importer import import_from_xml, perform_xlint
|
||||
from xmodule.modulestore.inheritance import own_metadata
|
||||
@@ -88,6 +87,8 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
self.user.is_active = True
|
||||
# Staff has access to view all courses
|
||||
self.user.is_staff = True
|
||||
|
||||
# Save the data that we've just changed to the db.
|
||||
self.user.save()
|
||||
|
||||
self.client = Client()
|
||||
@@ -118,6 +119,10 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
|
||||
course.advanced_modules = component_types
|
||||
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
course.save()
|
||||
|
||||
store.update_metadata(course.location, own_metadata(course))
|
||||
|
||||
# just pick one vertical
|
||||
@@ -135,7 +140,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
self.check_components_on_page(ADVANCED_COMPONENT_TYPES, ['Video Alpha',
|
||||
'Word cloud',
|
||||
'Annotation',
|
||||
'Open Ended Response',
|
||||
'Open Response Assessment',
|
||||
'Peer Grading Interface'])
|
||||
|
||||
def test_advanced_components_require_two_clicks(self):
|
||||
@@ -183,7 +188,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
|
||||
html_module = draft_store.get_item(['i4x', 'edX', 'simple', 'html', 'test_html', None])
|
||||
|
||||
draft_store.clone_item(html_module.location, html_module.location)
|
||||
draft_store.convert_to_draft(html_module.location)
|
||||
|
||||
# now query get_items() to get this location with revision=None, this should just
|
||||
# return back a single item (not 2)
|
||||
@@ -215,7 +220,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
self.assertEqual(html_module.lms.graceperiod, course.lms.graceperiod)
|
||||
self.assertNotIn('graceperiod', own_metadata(html_module))
|
||||
|
||||
draft_store.clone_item(html_module.location, html_module.location)
|
||||
draft_store.convert_to_draft(html_module.location)
|
||||
|
||||
# refetch to check metadata
|
||||
html_module = draft_store.get_item(['i4x', 'edX', 'simple', 'html', 'test_html', None])
|
||||
@@ -233,13 +238,16 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
self.assertNotIn('graceperiod', own_metadata(html_module))
|
||||
|
||||
# put back in draft and change metadata and see if it's now marked as 'own_metadata'
|
||||
draft_store.clone_item(html_module.location, html_module.location)
|
||||
draft_store.convert_to_draft(html_module.location)
|
||||
html_module = draft_store.get_item(['i4x', 'edX', 'simple', 'html', 'test_html', None])
|
||||
|
||||
new_graceperiod = timedelta(hours=1)
|
||||
|
||||
self.assertNotIn('graceperiod', own_metadata(html_module))
|
||||
html_module.lms.graceperiod = new_graceperiod
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
html_module.save()
|
||||
self.assertIn('graceperiod', own_metadata(html_module))
|
||||
self.assertEqual(html_module.lms.graceperiod, new_graceperiod)
|
||||
|
||||
@@ -255,7 +263,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
draft_store.publish(html_module.location, 0)
|
||||
|
||||
# and re-read and verify 'own-metadata'
|
||||
draft_store.clone_item(html_module.location, html_module.location)
|
||||
draft_store.convert_to_draft(html_module.location)
|
||||
html_module = draft_store.get_item(['i4x', 'edX', 'simple', 'html', 'test_html', None])
|
||||
|
||||
self.assertIn('graceperiod', own_metadata(html_module))
|
||||
@@ -278,7 +286,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
)
|
||||
|
||||
# put into draft
|
||||
modulestore('draft').clone_item(problem.location, problem.location)
|
||||
modulestore('draft').convert_to_draft(problem.location)
|
||||
|
||||
# make sure we can query that item and verify that it is a draft
|
||||
draft_problem = modulestore('draft').get_item(
|
||||
@@ -304,17 +312,36 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
|
||||
self.assertGreater(len(course.textbooks), 0)
|
||||
|
||||
def test_default_tabs_on_create_course(self):
|
||||
module_store = modulestore('direct')
|
||||
CourseFactory.create(org='edX', course='999', display_name='Robot Super Course')
|
||||
course_location = Location(['i4x', 'edX', '999', 'course', 'Robot_Super_Course', None])
|
||||
|
||||
course = module_store.get_item(course_location)
|
||||
|
||||
expected_tabs = []
|
||||
expected_tabs.append({u'type': u'courseware'})
|
||||
expected_tabs.append({u'type': u'course_info', u'name': u'Course Info'})
|
||||
expected_tabs.append({u'type': u'textbooks'})
|
||||
expected_tabs.append({u'type': u'discussion', u'name': u'Discussion'})
|
||||
expected_tabs.append({u'type': u'wiki', u'name': u'Wiki'})
|
||||
expected_tabs.append({u'type': u'progress', u'name': u'Progress'})
|
||||
|
||||
self.assertEqual(course.tabs, expected_tabs)
|
||||
|
||||
def test_static_tab_reordering(self):
|
||||
module_store = modulestore('direct')
|
||||
CourseFactory.create(org='edX', course='999', display_name='Robot Super Course')
|
||||
course_location = Location(['i4x', 'edX', '999', 'course', 'Robot_Super_Course', None])
|
||||
|
||||
ItemFactory.create(parent_location=course_location,
|
||||
template="i4x://edx/templates/static_tab/Empty",
|
||||
display_name="Static_1")
|
||||
ItemFactory.create(parent_location=course_location,
|
||||
template="i4x://edx/templates/static_tab/Empty",
|
||||
display_name="Static_2")
|
||||
ItemFactory.create(
|
||||
parent_location=course_location,
|
||||
category="static_tab",
|
||||
display_name="Static_1")
|
||||
ItemFactory.create(
|
||||
parent_location=course_location,
|
||||
category="static_tab",
|
||||
display_name="Static_2")
|
||||
|
||||
course = module_store.get_item(Location(['i4x', 'edX', '999', 'course', 'Robot_Super_Course', None]))
|
||||
|
||||
@@ -371,7 +398,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
course_location = Location(['i4x', 'edX', '999', 'course', 'Robot_Super_Course', None])
|
||||
|
||||
chapterloc = ItemFactory.create(parent_location=course_location, display_name="Chapter").location
|
||||
ItemFactory.create(parent_location=chapterloc, template='i4x://edx/templates/sequential/Empty', display_name="Sequential")
|
||||
ItemFactory.create(parent_location=chapterloc, category='sequential', display_name="Sequential")
|
||||
|
||||
sequential = direct_store.get_item(Location(['i4x', 'edX', '999', 'sequential', 'Sequential', None]))
|
||||
chapter = direct_store.get_item(Location(['i4x', 'edX', '999', 'chapter', 'Chapter', None]))
|
||||
@@ -574,7 +601,6 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
def test_clone_course(self):
|
||||
|
||||
course_data = {
|
||||
'template': 'i4x://edx/templates/course/Empty',
|
||||
'org': 'MITx',
|
||||
'number': '999',
|
||||
'display_name': 'Robot Super Course',
|
||||
@@ -614,10 +640,10 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course')
|
||||
|
||||
location = Location('i4x://MITx/999/chapter/neuvo')
|
||||
self.assertRaises(InvalidVersionError, draft_store.clone_item, 'i4x://edx/templates/chapter/Empty',
|
||||
location)
|
||||
direct_store.clone_item('i4x://edx/templates/chapter/Empty', location)
|
||||
self.assertRaises(InvalidVersionError, draft_store.clone_item, location, location)
|
||||
# Ensure draft mongo store does not allow us to create chapters either directly or via convert to draft
|
||||
self.assertRaises(InvalidVersionError, draft_store.create_and_save_xmodule, location)
|
||||
direct_store.create_and_save_xmodule(location)
|
||||
self.assertRaises(InvalidVersionError, draft_store.convert_to_draft, location)
|
||||
|
||||
self.assertRaises(InvalidVersionError, draft_store.update_item, location, 'chapter data')
|
||||
|
||||
@@ -652,9 +678,9 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
vertical = module_store.get_item(Location(['i4x', 'edX', 'toy',
|
||||
'vertical', 'vertical_test', None]), depth=1)
|
||||
|
||||
draft_store.clone_item(vertical.location, vertical.location)
|
||||
draft_store.convert_to_draft(vertical.location)
|
||||
for child in vertical.get_children():
|
||||
draft_store.clone_item(child.location, child.location)
|
||||
draft_store.convert_to_draft(child.location)
|
||||
|
||||
# delete the course
|
||||
delete_course(module_store, content_store, location, commit=True)
|
||||
@@ -687,26 +713,35 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
import_from_xml(module_store, 'common/test/data/', ['toy'])
|
||||
location = CourseDescriptor.id_to_location('edX/toy/2012_Fall')
|
||||
|
||||
# get a vertical (and components in it) to put into 'draft'
|
||||
vertical = module_store.get_item(Location(['i4x', 'edX', 'toy',
|
||||
'vertical', 'vertical_test', None]), depth=1)
|
||||
|
||||
draft_store.clone_item(vertical.location, vertical.location)
|
||||
|
||||
# get a vertical (and components in it) to copy into an orphan sub dag
|
||||
vertical = module_store.get_item(
|
||||
Location(['i4x', 'edX', 'toy', 'vertical', 'vertical_test', None]),
|
||||
depth=1
|
||||
)
|
||||
# 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', 'toy',
|
||||
'vertical', 'no_references', 'draft']))
|
||||
vertical.location = mongo.draft.as_draft(vertical.location.replace(name='no_references'))
|
||||
draft_store.save_xmodule(vertical)
|
||||
orphan_vertical = draft_store.get_item(vertical.location)
|
||||
self.assertEqual(orphan_vertical.location.name, 'no_references')
|
||||
|
||||
# get the original vertical (and components in it) to put into 'draft'
|
||||
vertical = module_store.get_item(
|
||||
Location(['i4x', 'edX', 'toy', 'vertical', 'vertical_test', None]),
|
||||
depth=1)
|
||||
self.assertEqual(len(orphan_vertical.children), len(vertical.children))
|
||||
draft_store.convert_to_draft(vertical.location)
|
||||
for child in vertical.get_children():
|
||||
draft_store.clone_item(child.location, child.location)
|
||||
draft_store.convert_to_draft(child.location)
|
||||
|
||||
root_dir = path(mkdtemp_clean())
|
||||
|
||||
# now create a private vertical
|
||||
private_vertical = draft_store.clone_item(vertical.location,
|
||||
Location(['i4x', 'edX', 'toy', 'vertical', 'a_private_vertical', None]))
|
||||
# now create a new/different private (draft only) vertical
|
||||
vertical.location = mongo.draft.as_draft(Location(['i4x', 'edX', 'toy', 'vertical', 'a_private_vertical', None]))
|
||||
draft_store.save_xmodule(vertical)
|
||||
private_vertical = draft_store.get_item(vertical.location)
|
||||
vertical = None # blank out b/c i destructively manipulated its location 2 lines above
|
||||
|
||||
# add private to list of children
|
||||
# add the new private to list of children
|
||||
sequential = module_store.get_item(Location(['i4x', 'edX', 'toy',
|
||||
'sequential', 'vertical_sequential', None]))
|
||||
private_location_no_draft = private_vertical.location.replace(revision=None)
|
||||
@@ -792,6 +827,34 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
|
||||
shutil.rmtree(root_dir)
|
||||
|
||||
def test_export_course_with_metadata_only_video(self):
|
||||
module_store = modulestore('direct')
|
||||
draft_store = modulestore('draft')
|
||||
content_store = contentstore()
|
||||
|
||||
import_from_xml(module_store, 'common/test/data/', ['toy'])
|
||||
location = CourseDescriptor.id_to_location('edX/toy/2012_Fall')
|
||||
|
||||
# create a new video module and add it as a child to a vertical
|
||||
# this re-creates a bug whereby since the video template doesn't have
|
||||
# anything in 'data' field, the export was blowing up
|
||||
verticals = module_store.get_items(['i4x', 'edX', 'toy', 'vertical', None, None])
|
||||
|
||||
self.assertGreater(len(verticals), 0)
|
||||
|
||||
parent = verticals[0]
|
||||
|
||||
ItemFactory.create(parent_location=parent.location, category="video", display_name="untitled")
|
||||
|
||||
root_dir = path(mkdtemp_clean())
|
||||
|
||||
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', draft_modulestore=draft_store)
|
||||
|
||||
shutil.rmtree(root_dir)
|
||||
|
||||
def test_course_handouts_rewrites(self):
|
||||
module_store = modulestore('direct')
|
||||
|
||||
@@ -846,6 +909,9 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
# add a bool piece of unknown metadata so we can verify we don't throw an exception
|
||||
metadata['new_metadata'] = True
|
||||
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
course.save()
|
||||
module_store.update_metadata(location, metadata)
|
||||
|
||||
print 'Exporting to tempdir = {0}'.format(root_dir)
|
||||
@@ -885,7 +951,6 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
self.client.login(username=uname, password=password)
|
||||
|
||||
self.course_data = {
|
||||
'template': 'i4x://edx/templates/course/Empty',
|
||||
'org': 'MITx',
|
||||
'number': '999',
|
||||
'display_name': 'Robot Super Course',
|
||||
@@ -1029,17 +1094,17 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
html=True
|
||||
)
|
||||
|
||||
def test_clone_item(self):
|
||||
def test_create_item(self):
|
||||
"""Test cloning an item. E.g. creating a new section"""
|
||||
CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course')
|
||||
|
||||
section_data = {
|
||||
'parent_location': 'i4x://MITx/999/course/Robot_Super_Course',
|
||||
'template': 'i4x://edx/templates/chapter/Empty',
|
||||
'category': 'chapter',
|
||||
'display_name': 'Section One',
|
||||
}
|
||||
|
||||
resp = self.client.post(reverse('clone_item'), section_data)
|
||||
resp = self.client.post(reverse('create_item'), section_data)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
data = parse_json(resp)
|
||||
@@ -1054,14 +1119,14 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
|
||||
problem_data = {
|
||||
'parent_location': 'i4x://MITx/999/course/Robot_Super_Course',
|
||||
'template': 'i4x://edx/templates/problem/Blank_Common_Problem'
|
||||
'category': 'problem'
|
||||
}
|
||||
|
||||
resp = self.client.post(reverse('clone_item'), problem_data)
|
||||
resp = self.client.post(reverse('create_item'), problem_data)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
payload = parse_json(resp)
|
||||
problem_loc = payload['id']
|
||||
problem_loc = Location(payload['id'])
|
||||
problem = get_modulestore(problem_loc).get_item(problem_loc)
|
||||
# should be a CapaDescriptor
|
||||
self.assertIsInstance(problem, CapaDescriptor, "New problem is not a CapaDescriptor")
|
||||
@@ -1194,10 +1259,9 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
CourseFactory.create(org='edX', course='999', display_name='Robot Super Course')
|
||||
|
||||
new_component_location = Location('i4x', 'edX', '999', '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)
|
||||
module_store.create_and_save_xmodule(new_component_location)
|
||||
|
||||
new_discussion_item = module_store.get_item(new_component_location)
|
||||
|
||||
@@ -1218,10 +1282,9 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
module_store.modulestore_update_signal.connect(_signal_hander)
|
||||
|
||||
new_component_location = Location('i4x', 'edX', '999', '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)
|
||||
module_store.create_and_save_xmodule(new_component_location)
|
||||
|
||||
finally:
|
||||
module_store.modulestore_update_signal = None
|
||||
@@ -1239,14 +1302,14 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
# let's assert on the metadata_inheritance on an existing vertical
|
||||
for vertical in verticals:
|
||||
self.assertEqual(course.lms.xqa_key, vertical.lms.xqa_key)
|
||||
self.assertEqual(course.start, vertical.lms.start)
|
||||
|
||||
self.assertGreater(len(verticals), 0)
|
||||
|
||||
new_component_location = Location('i4x', 'edX', 'toy', 'html', 'new_component')
|
||||
source_template_location = Location('i4x', 'edx', 'templates', 'html', 'Blank_HTML_Page')
|
||||
|
||||
# crate a new module and add it as a child to a vertical
|
||||
module_store.clone_item(source_template_location, new_component_location)
|
||||
module_store.create_and_save_xmodule(new_component_location)
|
||||
parent = verticals[0]
|
||||
module_store.update_children(parent.location, parent.children + [new_component_location.url()])
|
||||
|
||||
@@ -1256,6 +1319,8 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
|
||||
# check for grace period definition which should be defined at the course level
|
||||
self.assertEqual(parent.lms.graceperiod, new_module.lms.graceperiod)
|
||||
self.assertEqual(parent.lms.start, new_module.lms.start)
|
||||
self.assertEqual(course.start, new_module.lms.start)
|
||||
|
||||
self.assertEqual(course.lms.xqa_key, new_module.lms.xqa_key)
|
||||
|
||||
@@ -1263,6 +1328,7 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
# now let's define an override at the leaf node level
|
||||
#
|
||||
new_module.lms.graceperiod = timedelta(1)
|
||||
new_module.save()
|
||||
module_store.update_metadata(new_module.location, own_metadata(new_module))
|
||||
|
||||
# flush the cache and refetch
|
||||
@@ -1271,29 +1337,25 @@ class ContentStoreTest(ModuleStoreTestCase):
|
||||
|
||||
self.assertEqual(timedelta(1), new_module.lms.graceperiod)
|
||||
|
||||
def test_default_metadata_inheritance(self):
|
||||
course = CourseFactory.create()
|
||||
vertical = ItemFactory.create(parent_location=course.location)
|
||||
course.children.append(vertical)
|
||||
# in memory
|
||||
self.assertIsNotNone(course.start)
|
||||
self.assertEqual(course.start, vertical.lms.start)
|
||||
self.assertEqual(course.textbooks, [])
|
||||
self.assertIn('GRADER', course.grading_policy)
|
||||
self.assertIn('GRADE_CUTOFFS', course.grading_policy)
|
||||
self.assertGreaterEqual(len(course.checklists), 4)
|
||||
|
||||
class TemplateTestCase(ModuleStoreTestCase):
|
||||
|
||||
def test_template_cleanup(self):
|
||||
# by fetching
|
||||
module_store = modulestore('direct')
|
||||
|
||||
# insert a bogus template in the store
|
||||
bogus_template_location = Location('i4x', 'edx', 'templates', 'html', 'bogus')
|
||||
source_template_location = Location('i4x', 'edx', 'templates', 'html', 'Blank_HTML_Page')
|
||||
|
||||
module_store.clone_item(source_template_location, bogus_template_location)
|
||||
|
||||
verify_create = module_store.get_item(bogus_template_location)
|
||||
self.assertIsNotNone(verify_create)
|
||||
|
||||
# now run cleanup
|
||||
update_templates(modulestore('direct'))
|
||||
|
||||
# now try to find dangling template, it should not be in DB any longer
|
||||
asserted = False
|
||||
try:
|
||||
verify_create = module_store.get_item(bogus_template_location)
|
||||
except ItemNotFoundError:
|
||||
asserted = True
|
||||
|
||||
self.assertTrue(asserted)
|
||||
fetched_course = module_store.get_item(course.location)
|
||||
fetched_item = module_store.get_item(vertical.location)
|
||||
self.assertIsNotNone(fetched_course.start)
|
||||
self.assertEqual(course.start, fetched_course.start)
|
||||
self.assertEqual(fetched_course.start, fetched_item.lms.start)
|
||||
self.assertEqual(course.textbooks, fetched_course.textbooks)
|
||||
# is this test too strict? i.e., it requires the dicts to be ==
|
||||
self.assertEqual(course.checklists, fetched_course.checklists)
|
||||
|
||||
@@ -19,6 +19,7 @@ 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
|
||||
|
||||
from .utils import CourseTestCase
|
||||
@@ -36,7 +37,6 @@ class CourseDetailsTestCase(CourseTestCase):
|
||||
self.assertIsNone(details.enrollment_start, "enrollment_start date somehow initialized " + str(details.enrollment_start))
|
||||
self.assertIsNone(details.enrollment_end, "enrollment_end date somehow initialized " + str(details.enrollment_end))
|
||||
self.assertIsNone(details.syllabus, "syllabus somehow initialized" + str(details.syllabus))
|
||||
self.assertEqual(details.overview, "", "overview somehow initialized" + details.overview)
|
||||
self.assertIsNone(details.intro_video, "intro_video somehow initialized" + str(details.intro_video))
|
||||
self.assertIsNone(details.effort, "effort somehow initialized" + str(details.effort))
|
||||
|
||||
@@ -49,7 +49,6 @@ class CourseDetailsTestCase(CourseTestCase):
|
||||
self.assertIsNone(jsondetails['enrollment_start'], "enrollment_start date somehow initialized ")
|
||||
self.assertIsNone(jsondetails['enrollment_end'], "enrollment_end date somehow initialized ")
|
||||
self.assertIsNone(jsondetails['syllabus'], "syllabus somehow initialized")
|
||||
self.assertEqual(jsondetails['overview'], "", "overview somehow initialized")
|
||||
self.assertIsNone(jsondetails['intro_video'], "intro_video somehow initialized")
|
||||
self.assertIsNone(jsondetails['effort'], "effort somehow initialized")
|
||||
|
||||
@@ -291,6 +290,71 @@ 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")
|
||||
|
||||
def test_update_cutoffs_from_json(self):
|
||||
test_grader = CourseGradingModel.fetch(self.course.location)
|
||||
CourseGradingModel.update_cutoffs_from_json(test_grader.course_location, test_grader.grade_cutoffs)
|
||||
# Unlike other tests, need to actually perform a db fetch for this test since update_cutoffs_from_json
|
||||
# simply returns the cutoffs you send into it, rather than returning the db contents.
|
||||
altered_grader = CourseGradingModel.fetch(self.course.location)
|
||||
self.assertDictEqual(test_grader.grade_cutoffs, altered_grader.grade_cutoffs, "Noop update")
|
||||
|
||||
test_grader.grade_cutoffs['D'] = 0.3
|
||||
CourseGradingModel.update_cutoffs_from_json(test_grader.course_location, test_grader.grade_cutoffs)
|
||||
altered_grader = CourseGradingModel.fetch(self.course.location)
|
||||
self.assertDictEqual(test_grader.grade_cutoffs, altered_grader.grade_cutoffs, "cutoff add D")
|
||||
|
||||
test_grader.grade_cutoffs['Pass'] = 0.75
|
||||
CourseGradingModel.update_cutoffs_from_json(test_grader.course_location, test_grader.grade_cutoffs)
|
||||
altered_grader = CourseGradingModel.fetch(self.course.location)
|
||||
self.assertDictEqual(test_grader.grade_cutoffs, altered_grader.grade_cutoffs, "cutoff change 'Pass'")
|
||||
|
||||
def test_delete_grace_period(self):
|
||||
test_grader = CourseGradingModel.fetch(self.course.location)
|
||||
CourseGradingModel.update_grace_period_from_json(test_grader.course_location, test_grader.grace_period)
|
||||
# update_grace_period_from_json doesn't return anything, so query the db for its contents.
|
||||
altered_grader = CourseGradingModel.fetch(self.course.location)
|
||||
self.assertEqual(test_grader.grace_period, altered_grader.grace_period, "Noop update")
|
||||
|
||||
test_grader.grace_period = {'hours': 15, 'minutes': 5, 'seconds': 30}
|
||||
CourseGradingModel.update_grace_period_from_json(test_grader.course_location, test_grader.grace_period)
|
||||
altered_grader = CourseGradingModel.fetch(self.course.location)
|
||||
self.assertDictEqual(test_grader.grace_period, altered_grader.grace_period, "Adding in a grace period")
|
||||
|
||||
test_grader.grace_period = {'hours': 1, 'minutes': 10, 'seconds': 0}
|
||||
# Now delete the grace period
|
||||
CourseGradingModel.delete_grace_period(test_grader.course_location)
|
||||
# update_grace_period_from_json doesn't return anything, so query the db for its contents.
|
||||
altered_grader = CourseGradingModel.fetch(self.course.location)
|
||||
# Once deleted, the grace period should simply be None
|
||||
self.assertEqual(None, altered_grader.grace_period, "Delete grace period")
|
||||
|
||||
def test_update_section_grader_type(self):
|
||||
# Get the descriptor and the section_grader_type and assert they are the default values
|
||||
descriptor = get_modulestore(self.course.location).get_item(self.course.location)
|
||||
section_grader_type = CourseGradingModel.get_section_grader_type(self.course.location)
|
||||
|
||||
self.assertEqual('Not Graded', section_grader_type['graderType'])
|
||||
self.assertEqual(None, descriptor.lms.format)
|
||||
self.assertEqual(False, descriptor.lms.graded)
|
||||
|
||||
# Change the default grader type to Homework, which should also mark the section as graded
|
||||
CourseGradingModel.update_section_grader_type(self.course.location, {'graderType': 'Homework'})
|
||||
descriptor = get_modulestore(self.course.location).get_item(self.course.location)
|
||||
section_grader_type = CourseGradingModel.get_section_grader_type(self.course.location)
|
||||
|
||||
self.assertEqual('Homework', section_grader_type['graderType'])
|
||||
self.assertEqual('Homework', descriptor.lms.format)
|
||||
self.assertEqual(True, descriptor.lms.graded)
|
||||
|
||||
# Change the grader type back to Not Graded, which should also unmark the section as graded
|
||||
CourseGradingModel.update_section_grader_type(self.course.location, {'graderType': 'Not Graded'})
|
||||
descriptor = get_modulestore(self.course.location).get_item(self.course.location)
|
||||
section_grader_type = CourseGradingModel.get_section_grader_type(self.course.location)
|
||||
|
||||
self.assertEqual('Not Graded', section_grader_type['graderType'])
|
||||
self.assertEqual(None, descriptor.lms.format)
|
||||
self.assertEqual(False, descriptor.lms.graded)
|
||||
|
||||
|
||||
class CourseMetadataEditingTest(CourseTestCase):
|
||||
"""
|
||||
@@ -352,7 +416,7 @@ class CourseMetadataEditingTest(CourseTestCase):
|
||||
self.assertEqual(test_model['display_name'], 'Robot Super Course', "not expected value")
|
||||
self.assertIn('rerandomize', test_model, 'Missing rerandomize metadata field')
|
||||
# check for deletion effectiveness
|
||||
self.assertEqual('closed', test_model['showanswer'], 'showanswer field still in')
|
||||
self.assertEqual('finished', test_model['showanswer'], 'showanswer field still in')
|
||||
self.assertEqual(None, test_model['xqa_key'], 'xqa_key field still in')
|
||||
|
||||
|
||||
|
||||
@@ -36,8 +36,11 @@ class CourseUpdateTest(CourseTestCase):
|
||||
'provided_id': payload['id']})
|
||||
content += '<div>div <p>p<br/></p></div>'
|
||||
payload['content'] = content
|
||||
# POST requests were coming in w/ these header values causing an error; so, repro error here
|
||||
resp = self.client.post(first_update_url, json.dumps(payload),
|
||||
"application/json")
|
||||
"application/json",
|
||||
HTTP_X_HTTP_METHOD_OVERRIDE="PUT",
|
||||
REQUEST_METHOD="POST")
|
||||
|
||||
self.assertHTMLEqual(content, json.loads(resp.content)['content'],
|
||||
"iframe w/ div")
|
||||
|
||||
186
cms/djangoapps/contentstore/tests/test_crud.py
Normal file
186
cms/djangoapps/contentstore/tests/test_crud.py
Normal file
@@ -0,0 +1,186 @@
|
||||
'''
|
||||
Created on May 7, 2013
|
||||
|
||||
@author: dmitchell
|
||||
'''
|
||||
import unittest
|
||||
from xmodule import templates
|
||||
from xmodule.modulestore.tests import persistent_factories
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.seq_module import SequenceDescriptor
|
||||
from xmodule.x_module import XModuleDescriptor
|
||||
from xmodule.capa_module import CapaDescriptor
|
||||
from xmodule.modulestore.locator import CourseLocator, BlockUsageLocator
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from xmodule.html_module import HtmlDescriptor
|
||||
|
||||
|
||||
class TemplateTests(unittest.TestCase):
|
||||
"""
|
||||
Test finding and using the templates (boilerplates) for xblocks.
|
||||
"""
|
||||
|
||||
def test_get_templates(self):
|
||||
found = templates.all_templates()
|
||||
self.assertIsNotNone(found.get('course'))
|
||||
self.assertIsNotNone(found.get('about'))
|
||||
self.assertIsNotNone(found.get('html'))
|
||||
self.assertIsNotNone(found.get('problem'))
|
||||
self.assertEqual(len(found.get('course')), 0)
|
||||
self.assertEqual(len(found.get('about')), 1)
|
||||
self.assertGreaterEqual(len(found.get('html')), 2)
|
||||
self.assertGreaterEqual(len(found.get('problem')), 10)
|
||||
dropdown = None
|
||||
for template in found['problem']:
|
||||
self.assertIn('metadata', template)
|
||||
self.assertIn('display_name', template['metadata'])
|
||||
if template['metadata']['display_name'] == 'Dropdown':
|
||||
dropdown = template
|
||||
break
|
||||
self.assertIsNotNone(dropdown)
|
||||
self.assertIn('markdown', dropdown['metadata'])
|
||||
self.assertIn('data', dropdown)
|
||||
self.assertRegexpMatches(dropdown['metadata']['markdown'], r'^Dropdown.*')
|
||||
self.assertRegexpMatches(dropdown['data'], r'<problem>\s*<p>Dropdown.*')
|
||||
|
||||
def test_get_some_templates(self):
|
||||
self.assertEqual(len(SequenceDescriptor.templates()), 0)
|
||||
self.assertGreater(len(HtmlDescriptor.templates()), 0)
|
||||
self.assertIsNone(SequenceDescriptor.get_template('doesntexist.yaml'))
|
||||
self.assertIsNone(HtmlDescriptor.get_template('doesntexist.yaml'))
|
||||
self.assertIsNotNone(HtmlDescriptor.get_template('announcement.yaml'))
|
||||
|
||||
def test_factories(self):
|
||||
test_course = persistent_factories.PersistentCourseFactory.create(org='testx', prettyid='tempcourse',
|
||||
display_name='fun test course', user_id='testbot')
|
||||
self.assertIsInstance(test_course, CourseDescriptor)
|
||||
self.assertEqual(test_course.display_name, 'fun test course')
|
||||
index_info = modulestore('split').get_course_index_info(test_course.location)
|
||||
self.assertEqual(index_info['org'], 'testx')
|
||||
self.assertEqual(index_info['prettyid'], 'tempcourse')
|
||||
|
||||
test_chapter = persistent_factories.ItemFactory.create(display_name='chapter 1',
|
||||
parent_location=test_course.location)
|
||||
self.assertIsInstance(test_chapter, SequenceDescriptor)
|
||||
# refetch parent which should now point to child
|
||||
test_course = modulestore('split').get_course(test_chapter.location)
|
||||
self.assertIn(test_chapter.location.usage_id, test_course.children)
|
||||
|
||||
def test_temporary_xblocks(self):
|
||||
"""
|
||||
Test using load_from_json to create non persisted xblocks
|
||||
"""
|
||||
test_course = persistent_factories.PersistentCourseFactory.create(org='testx', prettyid='tempcourse',
|
||||
display_name='fun test course', user_id='testbot')
|
||||
|
||||
test_chapter = XModuleDescriptor.load_from_json({'category': 'chapter',
|
||||
'metadata': {'display_name': 'chapter n'}},
|
||||
test_course.system, parent_xblock=test_course)
|
||||
self.assertIsInstance(test_chapter, SequenceDescriptor)
|
||||
self.assertEqual(test_chapter.display_name, 'chapter n')
|
||||
self.assertIn(test_chapter, test_course.get_children())
|
||||
|
||||
# test w/ a definition (e.g., a problem)
|
||||
test_def_content = '<problem>boo</problem>'
|
||||
test_problem = XModuleDescriptor.load_from_json({'category': 'problem',
|
||||
'definition': {'data': test_def_content}},
|
||||
test_course.system, parent_xblock=test_chapter)
|
||||
self.assertIsInstance(test_problem, CapaDescriptor)
|
||||
self.assertEqual(test_problem.data, test_def_content)
|
||||
self.assertIn(test_problem, test_chapter.get_children())
|
||||
test_problem.display_name = 'test problem'
|
||||
self.assertEqual(test_problem.display_name, 'test problem')
|
||||
|
||||
def test_persist_dag(self):
|
||||
"""
|
||||
try saving temporary xblocks
|
||||
"""
|
||||
test_course = persistent_factories.PersistentCourseFactory.create(org='testx', prettyid='tempcourse',
|
||||
display_name='fun test course', user_id='testbot')
|
||||
test_chapter = XModuleDescriptor.load_from_json({'category': 'chapter',
|
||||
'metadata': {'display_name': 'chapter n'}},
|
||||
test_course.system, parent_xblock=test_course)
|
||||
test_def_content = '<problem>boo</problem>'
|
||||
test_problem = XModuleDescriptor.load_from_json({'category': 'problem',
|
||||
'definition': {'data': test_def_content}},
|
||||
test_course.system, parent_xblock=test_chapter)
|
||||
# better to pass in persisted parent over the subdag so
|
||||
# subdag gets the parent pointer (otherwise 2 ops, persist dag, update parent children,
|
||||
# persist parent
|
||||
persisted_course = modulestore('split').persist_xblock_dag(test_course, 'testbot')
|
||||
self.assertEqual(len(persisted_course.children), 1)
|
||||
persisted_chapter = persisted_course.get_children()[0]
|
||||
self.assertEqual(persisted_chapter.category, 'chapter')
|
||||
self.assertEqual(persisted_chapter.display_name, 'chapter n')
|
||||
self.assertEqual(len(persisted_chapter.children), 1)
|
||||
persisted_problem = persisted_chapter.get_children()[0]
|
||||
self.assertEqual(persisted_problem.category, 'problem')
|
||||
self.assertEqual(persisted_problem.data, test_def_content)
|
||||
|
||||
def test_delete_course(self):
|
||||
test_course = persistent_factories.PersistentCourseFactory.create(
|
||||
org='testx',
|
||||
prettyid='edu.harvard.history.doomed',
|
||||
display_name='doomed test course',
|
||||
user_id='testbot')
|
||||
persistent_factories.ItemFactory.create(display_name='chapter 1',
|
||||
parent_location=test_course.location)
|
||||
|
||||
id_locator = CourseLocator(course_id=test_course.location.course_id, revision='draft')
|
||||
guid_locator = CourseLocator(version_guid=test_course.location.version_guid)
|
||||
# verify it can be retireved by id
|
||||
self.assertIsInstance(modulestore('split').get_course(id_locator), CourseDescriptor)
|
||||
# and by guid
|
||||
self.assertIsInstance(modulestore('split').get_course(guid_locator), CourseDescriptor)
|
||||
modulestore('split').delete_course(id_locator.course_id)
|
||||
# test can no longer retrieve by id
|
||||
self.assertRaises(ItemNotFoundError, modulestore('split').get_course, id_locator)
|
||||
# but can by guid
|
||||
self.assertIsInstance(modulestore('split').get_course(guid_locator), CourseDescriptor)
|
||||
|
||||
def test_block_generations(self):
|
||||
"""
|
||||
Test get_block_generations
|
||||
"""
|
||||
test_course = persistent_factories.PersistentCourseFactory.create(
|
||||
org='testx',
|
||||
prettyid='edu.harvard.history.hist101',
|
||||
display_name='history test course',
|
||||
user_id='testbot')
|
||||
chapter = persistent_factories.ItemFactory.create(display_name='chapter 1',
|
||||
parent_location=test_course.location, user_id='testbot')
|
||||
sub = persistent_factories.ItemFactory.create(display_name='subsection 1',
|
||||
parent_location=chapter.location, user_id='testbot', category='vertical')
|
||||
first_problem = persistent_factories.ItemFactory.create(display_name='problem 1',
|
||||
parent_location=sub.location, user_id='testbot', category='problem', data="<problem></problem>")
|
||||
first_problem.max_attempts = 3
|
||||
updated_problem = modulestore('split').update_item(first_problem, 'testbot')
|
||||
updated_loc = modulestore('split').delete_item(updated_problem.location, 'testbot')
|
||||
|
||||
second_problem = persistent_factories.ItemFactory.create(display_name='problem 2',
|
||||
parent_location=BlockUsageLocator(updated_loc, usage_id=sub.location.usage_id),
|
||||
user_id='testbot', category='problem', data="<problem></problem>")
|
||||
|
||||
# course root only updated 2x
|
||||
version_history = modulestore('split').get_block_generations(test_course.location)
|
||||
self.assertEqual(version_history.locator.version_guid, test_course.location.version_guid)
|
||||
self.assertEqual(len(version_history.children), 1)
|
||||
self.assertEqual(version_history.children[0].children, [])
|
||||
self.assertEqual(version_history.children[0].locator.version_guid, chapter.location.version_guid)
|
||||
|
||||
# sub changed on add, add problem, delete problem, add problem in strict linear seq
|
||||
version_history = modulestore('split').get_block_generations(sub.location)
|
||||
self.assertEqual(len(version_history.children), 1)
|
||||
self.assertEqual(len(version_history.children[0].children), 1)
|
||||
self.assertEqual(len(version_history.children[0].children[0].children), 1)
|
||||
self.assertEqual(len(version_history.children[0].children[0].children[0].children), 0)
|
||||
|
||||
# first and second problem may show as same usage_id; so, need to ensure their histories are right
|
||||
version_history = modulestore('split').get_block_generations(updated_problem.location)
|
||||
self.assertEqual(version_history.locator.version_guid, first_problem.location.version_guid)
|
||||
self.assertEqual(len(version_history.children), 1) # updated max_attempts
|
||||
self.assertEqual(len(version_history.children[0].children), 0)
|
||||
|
||||
version_history = modulestore('split').get_block_generations(second_problem.location)
|
||||
self.assertNotEqual(version_history.locator.version_guid, first_problem.location.version_guid)
|
||||
@@ -35,7 +35,6 @@ class InternationalizationTest(ModuleStoreTestCase):
|
||||
self.user.save()
|
||||
|
||||
self.course_data = {
|
||||
'template': 'i4x://edx/templates/course/Empty',
|
||||
'org': 'MITx',
|
||||
'number': '999',
|
||||
'display_name': 'Robot Super Course',
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
from contentstore.tests.test_course_settings import CourseTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from django.core.urlresolvers import reverse
|
||||
from xmodule.capa_module import CapaDescriptor
|
||||
import json
|
||||
from xmodule.modulestore.django import modulestore
|
||||
import datetime
|
||||
from pytz import UTC
|
||||
|
||||
|
||||
class DeleteItem(CourseTestCase):
|
||||
@@ -11,14 +16,228 @@ class DeleteItem(CourseTestCase):
|
||||
|
||||
def testDeleteStaticPage(self):
|
||||
# Add static tab
|
||||
data = {
|
||||
data = json.dumps({
|
||||
'parent_location': 'i4x://mitX/333/course/Dummy_Course',
|
||||
'template': 'i4x://edx/templates/static_tab/Empty'
|
||||
}
|
||||
'category': 'static_tab'
|
||||
})
|
||||
|
||||
resp = self.client.post(reverse('clone_item'), data)
|
||||
resp = self.client.post(reverse('create_item'), data,
|
||||
content_type="application/json")
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
# Now delete it. There was a bug that the delete was failing (static tabs do not exist in draft modulestore).
|
||||
resp = self.client.post(reverse('delete_item'), resp.content, "application/json")
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
|
||||
class TestCreateItem(CourseTestCase):
|
||||
"""
|
||||
Test the create_item handler thoroughly
|
||||
"""
|
||||
def response_id(self, response):
|
||||
"""
|
||||
Get the id from the response payload
|
||||
:param response:
|
||||
"""
|
||||
parsed = json.loads(response.content)
|
||||
return parsed['id']
|
||||
|
||||
def test_create_nicely(self):
|
||||
"""
|
||||
Try the straightforward use cases
|
||||
"""
|
||||
# create a chapter
|
||||
display_name = 'Nicely created'
|
||||
resp = self.client.post(
|
||||
reverse('create_item'),
|
||||
json.dumps({
|
||||
'parent_location': self.course.location.url(),
|
||||
'display_name': display_name,
|
||||
'category': 'chapter'
|
||||
}),
|
||||
content_type="application/json"
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
# get the new item and check its category and display_name
|
||||
chap_location = self.response_id(resp)
|
||||
new_obj = modulestore().get_item(chap_location)
|
||||
self.assertEqual(new_obj.category, 'chapter')
|
||||
self.assertEqual(new_obj.display_name, display_name)
|
||||
self.assertEqual(new_obj.location.org, self.course.location.org)
|
||||
self.assertEqual(new_obj.location.course, self.course.location.course)
|
||||
|
||||
# get the course and ensure it now points to this one
|
||||
course = modulestore().get_item(self.course.location)
|
||||
self.assertIn(chap_location, course.children)
|
||||
|
||||
# use default display name
|
||||
resp = self.client.post(
|
||||
reverse('create_item'),
|
||||
json.dumps({
|
||||
'parent_location': chap_location,
|
||||
'category': 'vertical'
|
||||
}),
|
||||
content_type="application/json"
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
vert_location = self.response_id(resp)
|
||||
|
||||
# create problem w/ boilerplate
|
||||
template_id = 'multiplechoice.yaml'
|
||||
resp = self.client.post(
|
||||
reverse('create_item'),
|
||||
json.dumps({
|
||||
'parent_location': vert_location,
|
||||
'category': 'problem',
|
||||
'boilerplate': template_id
|
||||
}),
|
||||
content_type="application/json"
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
prob_location = self.response_id(resp)
|
||||
problem = modulestore('draft').get_item(prob_location)
|
||||
# ensure it's draft
|
||||
self.assertTrue(problem.is_draft)
|
||||
# check against the template
|
||||
template = CapaDescriptor.get_template(template_id)
|
||||
self.assertEqual(problem.data, template['data'])
|
||||
self.assertEqual(problem.display_name, template['metadata']['display_name'])
|
||||
self.assertEqual(problem.markdown, template['metadata']['markdown'])
|
||||
|
||||
def test_create_item_negative(self):
|
||||
"""
|
||||
Negative tests for create_item
|
||||
"""
|
||||
# non-existent boilerplate: creates a default
|
||||
resp = self.client.post(
|
||||
reverse('create_item'),
|
||||
json.dumps(
|
||||
{'parent_location': self.course.location.url(),
|
||||
'category': 'problem',
|
||||
'boilerplate': 'nosuchboilerplate.yaml'
|
||||
}),
|
||||
content_type="application/json"
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
class TestEditItem(CourseTestCase):
|
||||
"""
|
||||
Test contentstore.views.item.save_item
|
||||
"""
|
||||
def response_id(self, response):
|
||||
"""
|
||||
Get the id from the response payload
|
||||
:param response:
|
||||
"""
|
||||
parsed = json.loads(response.content)
|
||||
return parsed['id']
|
||||
|
||||
def setUp(self):
|
||||
""" Creates the test course structure and a couple problems to 'edit'. """
|
||||
super(TestEditItem, self).setUp()
|
||||
# create a chapter
|
||||
display_name = 'chapter created'
|
||||
resp = self.client.post(
|
||||
reverse('create_item'),
|
||||
json.dumps(
|
||||
{'parent_location': self.course.location.url(),
|
||||
'display_name': display_name,
|
||||
'category': 'chapter'
|
||||
}),
|
||||
content_type="application/json"
|
||||
)
|
||||
chap_location = self.response_id(resp)
|
||||
resp = self.client.post(
|
||||
reverse('create_item'),
|
||||
json.dumps(
|
||||
{'parent_location': chap_location,
|
||||
'category': 'sequential'
|
||||
}),
|
||||
content_type="application/json"
|
||||
)
|
||||
self.seq_location = self.response_id(resp)
|
||||
# create problem w/ boilerplate
|
||||
template_id = 'multiplechoice.yaml'
|
||||
resp = self.client.post(
|
||||
reverse('create_item'),
|
||||
json.dumps({'parent_location': self.seq_location,
|
||||
'category': 'problem',
|
||||
'boilerplate': template_id
|
||||
}),
|
||||
content_type="application/json"
|
||||
)
|
||||
self.problems = [self.response_id(resp)]
|
||||
|
||||
def test_delete_field(self):
|
||||
"""
|
||||
Sending null in for a field 'deletes' it
|
||||
"""
|
||||
self.client.post(
|
||||
reverse('save_item'),
|
||||
json.dumps({
|
||||
'id': self.problems[0],
|
||||
'metadata': {'rerandomize': 'onreset'}
|
||||
}),
|
||||
content_type="application/json"
|
||||
)
|
||||
problem = modulestore('draft').get_item(self.problems[0])
|
||||
self.assertEqual(problem.rerandomize, 'onreset')
|
||||
self.client.post(
|
||||
reverse('save_item'),
|
||||
json.dumps({
|
||||
'id': self.problems[0],
|
||||
'metadata': {'rerandomize': None}
|
||||
}),
|
||||
content_type="application/json"
|
||||
)
|
||||
problem = modulestore('draft').get_item(self.problems[0])
|
||||
self.assertEqual(problem.rerandomize, 'never')
|
||||
|
||||
|
||||
def test_null_field(self):
|
||||
"""
|
||||
Sending null in for a field 'deletes' it
|
||||
"""
|
||||
problem = modulestore('draft').get_item(self.problems[0])
|
||||
self.assertIsNotNone(problem.markdown)
|
||||
self.client.post(
|
||||
reverse('save_item'),
|
||||
json.dumps({
|
||||
'id': self.problems[0],
|
||||
'nullout': ['markdown']
|
||||
}),
|
||||
content_type="application/json"
|
||||
)
|
||||
problem = modulestore('draft').get_item(self.problems[0])
|
||||
self.assertIsNone(problem.markdown)
|
||||
|
||||
def test_date_fields(self):
|
||||
"""
|
||||
Test setting due & start dates on sequential
|
||||
"""
|
||||
sequential = modulestore().get_item(self.seq_location)
|
||||
self.assertIsNone(sequential.lms.due)
|
||||
self.client.post(
|
||||
reverse('save_item'),
|
||||
json.dumps({
|
||||
'id': self.seq_location,
|
||||
'metadata': {'due': '2010-11-22T04:00Z'}
|
||||
}),
|
||||
content_type="application/json"
|
||||
)
|
||||
sequential = modulestore().get_item(self.seq_location)
|
||||
self.assertEqual(sequential.lms.due, datetime.datetime(2010, 11, 22, 4, 0, tzinfo=UTC))
|
||||
self.client.post(
|
||||
reverse('save_item'),
|
||||
json.dumps({
|
||||
'id': self.seq_location,
|
||||
'metadata': {'start': '2010-09-12T14:00Z'}
|
||||
}),
|
||||
content_type="application/json"
|
||||
)
|
||||
sequential = modulestore().get_item(self.seq_location)
|
||||
self.assertEqual(sequential.lms.due, datetime.datetime(2010, 11, 22, 4, 0, tzinfo=UTC))
|
||||
self.assertEqual(sequential.lms.start, datetime.datetime(2010, 9, 12, 14, 0, tzinfo=UTC))
|
||||
|
||||
|
||||
@@ -62,6 +62,9 @@ class TextbookIndexTestCase(CourseTestCase):
|
||||
}
|
||||
]
|
||||
self.course.pdf_textbooks = content
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
self.course.save()
|
||||
store = get_modulestore(self.course.location)
|
||||
store.update_metadata(self.course.location, own_metadata(self.course))
|
||||
|
||||
@@ -220,6 +223,9 @@ class TextbookByIdTestCase(CourseTestCase):
|
||||
'tid': 2,
|
||||
})
|
||||
self.course.pdf_textbooks = [self.textbook1, self.textbook2]
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
self.course.save()
|
||||
self.store = get_modulestore(self.course.location)
|
||||
self.store.update_metadata(self.course.location, own_metadata(self.course))
|
||||
self.url_nonexist = reverse('textbook_by_id', kwargs={
|
||||
|
||||
@@ -54,7 +54,6 @@ class CourseTestCase(ModuleStoreTestCase):
|
||||
self.client.login(username=uname, password=password)
|
||||
|
||||
self.course = CourseFactory.create(
|
||||
template='i4x://edx/templates/course/Empty',
|
||||
org='MITx',
|
||||
number='999',
|
||||
display_name='Robot Super Course',
|
||||
|
||||
@@ -19,14 +19,14 @@ NOTES_PANEL = {"name": _("My Notes"), "type": "notes"}
|
||||
EXTRA_TAB_PANELS = dict([(p['type'], p) for p in [OPEN_ENDED_PANEL, NOTES_PANEL]])
|
||||
|
||||
|
||||
def get_modulestore(location):
|
||||
def get_modulestore(category_or_location):
|
||||
"""
|
||||
Returns the correct modulestore to use for modifying the specified location
|
||||
"""
|
||||
if not isinstance(location, Location):
|
||||
location = Location(location)
|
||||
if isinstance(category_or_location, Location):
|
||||
category_or_location = category_or_location.category
|
||||
|
||||
if location.category in DIRECT_ONLY_CATEGORIES:
|
||||
if category_or_location in DIRECT_ONLY_CATEGORIES:
|
||||
return modulestore('direct')
|
||||
else:
|
||||
return modulestore()
|
||||
|
||||
@@ -13,7 +13,7 @@ from django_future.csrf import ensure_csrf_cookie
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.core.servers.basehttp import FileWrapper
|
||||
from django.core.files.temp import NamedTemporaryFile
|
||||
from django.views.decorators.http import require_POST
|
||||
from django.views.decorators.http import require_POST, require_http_methods
|
||||
|
||||
from mitxmako.shortcuts import render_to_response
|
||||
from cache_toolbox.core import del_cached_content
|
||||
@@ -249,6 +249,7 @@ def remove_asset(request, org, course, name):
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@require_http_methods(("GET", "POST", "PUT"))
|
||||
@login_required
|
||||
def import_course(request, org, course, name):
|
||||
"""
|
||||
@@ -256,7 +257,7 @@ def import_course(request, org, course, name):
|
||||
"""
|
||||
location = get_location_and_verify_access(request, org, course, name)
|
||||
|
||||
if request.method == 'POST':
|
||||
if request.method in ('POST', 'PUT'):
|
||||
filename = request.FILES['course-data'].name
|
||||
|
||||
if not filename.endswith('.tar.gz'):
|
||||
|
||||
@@ -7,11 +7,11 @@ from django.views.decorators.http import require_http_methods
|
||||
from django_future.csrf import ensure_csrf_cookie
|
||||
from mitxmako.shortcuts import render_to_response
|
||||
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.inheritance import own_metadata
|
||||
|
||||
from ..utils import get_modulestore, get_url_reverse
|
||||
from .access import get_location_and_verify_access
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
|
||||
__all__ = ['get_checklists', 'update_checklist']
|
||||
|
||||
@@ -28,17 +28,16 @@ def get_checklists(request, org, course, name):
|
||||
|
||||
modulestore = get_modulestore(location)
|
||||
course_module = modulestore.get_item(location)
|
||||
new_course_template = Location('i4x', 'edx', 'templates', 'course', 'Empty')
|
||||
template_module = modulestore.get_item(new_course_template)
|
||||
|
||||
# If course was created before checklists were introduced, copy them over from the template.
|
||||
copied = False
|
||||
if not course_module.checklists:
|
||||
course_module.checklists = template_module.checklists
|
||||
course_module.checklists = CourseDescriptor.checklists.default
|
||||
copied = True
|
||||
|
||||
checklists, modified = expand_checklist_action_urls(course_module)
|
||||
if copied or modified:
|
||||
course_module.save()
|
||||
modulestore.update_metadata(location, own_metadata(course_module))
|
||||
return render_to_response('checklists.html',
|
||||
{
|
||||
@@ -71,6 +70,7 @@ def update_checklist(request, org, course, name, checklist_index=None):
|
||||
# seeming noop which triggers kvs to record that the metadata is not default
|
||||
course_module.checklists = course_module.checklists
|
||||
checklists, _ = expand_checklist_action_urls(course_module)
|
||||
course_module.save()
|
||||
modulestore.update_metadata(location, own_metadata(course_module))
|
||||
return JsonResponse(checklists[index])
|
||||
else:
|
||||
@@ -81,6 +81,7 @@ def update_checklist(request, org, course, name, checklist_index=None):
|
||||
# In the JavaScript view initialize method, we do a fetch to get all the checklists.
|
||||
checklists, modified = expand_checklist_action_urls(course_module)
|
||||
if modified:
|
||||
course_module.save()
|
||||
modulestore.update_metadata(location, own_metadata(course_module))
|
||||
return JsonResponse(checklists)
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ from models.settings.course_grading import CourseGradingModel
|
||||
|
||||
from .requests import _xmodule_recurse
|
||||
from .access import has_access
|
||||
from xmodule.x_module import XModuleDescriptor
|
||||
from xblock.plugin import PluginMissingError
|
||||
|
||||
__all__ = ['OPEN_ENDED_COMPONENT_TYPES',
|
||||
'ADVANCED_COMPONENT_POLICY_KEY',
|
||||
@@ -101,7 +103,7 @@ def edit_subsection(request, location):
|
||||
return render_to_response('edit_subsection.html',
|
||||
{'subsection': item,
|
||||
'context_course': course,
|
||||
'create_new_unit_template': Location('i4x', 'edx', 'templates', 'vertical', 'Empty'),
|
||||
'new_unit_category': 'vertical',
|
||||
'lms_link': lms_link,
|
||||
'preview_link': preview_link,
|
||||
'course_graders': json.dumps(CourseGradingModel.fetch(course.location).graders),
|
||||
@@ -134,10 +136,26 @@ def edit_unit(request, location):
|
||||
item = modulestore().get_item(location, depth=1)
|
||||
except ItemNotFoundError:
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
lms_link = get_lms_link_for_item(item.location, course_id=course.location.course_id)
|
||||
|
||||
component_templates = defaultdict(list)
|
||||
for category in COMPONENT_TYPES:
|
||||
component_class = XModuleDescriptor.load_class(category)
|
||||
# add the default template
|
||||
component_templates[category].append((
|
||||
component_class.display_name.default or 'Blank',
|
||||
category,
|
||||
False, # No defaults have markdown (hardcoded current default)
|
||||
None # no boilerplate for overrides
|
||||
))
|
||||
# add boilerplates
|
||||
for template in component_class.templates():
|
||||
component_templates[category].append((
|
||||
template['metadata'].get('display_name'),
|
||||
category,
|
||||
template['metadata'].get('markdown') is not None,
|
||||
template.get('template_id')
|
||||
))
|
||||
|
||||
# Check if there are any advanced modules specified in the course policy. These modules
|
||||
# should be specified as a list of strings, where the strings are the names of the modules
|
||||
@@ -145,29 +163,29 @@ def edit_unit(request, location):
|
||||
course_advanced_keys = course.advanced_modules
|
||||
|
||||
# Set component types according to course policy file
|
||||
component_types = list(COMPONENT_TYPES)
|
||||
if isinstance(course_advanced_keys, list):
|
||||
course_advanced_keys = [c for c in course_advanced_keys if c in ADVANCED_COMPONENT_TYPES]
|
||||
if len(course_advanced_keys) > 0:
|
||||
component_types.append(ADVANCED_COMPONENT_CATEGORY)
|
||||
for category in course_advanced_keys:
|
||||
if category in ADVANCED_COMPONENT_TYPES:
|
||||
# Do I need to allow for boilerplates or just defaults on the class? i.e., can an advanced
|
||||
# have more than one entry in the menu? one for default and others for prefilled boilerplates?
|
||||
try:
|
||||
component_class = XModuleDescriptor.load_class(category)
|
||||
|
||||
component_templates['advanced'].append((
|
||||
component_class.display_name.default or category,
|
||||
category,
|
||||
False,
|
||||
None # don't override default data
|
||||
))
|
||||
except PluginMissingError:
|
||||
# dhm: I got this once but it can happen any time the course author configures
|
||||
# an advanced component which does not exist on the server. This code here merely
|
||||
# prevents any authors from trying to instantiate the non-existent component type
|
||||
# by not showing it in the menu
|
||||
pass
|
||||
else:
|
||||
log.error("Improper format for course advanced keys! {0}".format(course_advanced_keys))
|
||||
|
||||
templates = modulestore().get_items(Location('i4x', 'edx', 'templates'))
|
||||
for template in templates:
|
||||
category = template.location.category
|
||||
|
||||
if category in course_advanced_keys:
|
||||
category = ADVANCED_COMPONENT_CATEGORY
|
||||
|
||||
if category in component_types:
|
||||
# This is a hack to create categories for different xmodules
|
||||
component_templates[category].append((
|
||||
template.display_name_with_default,
|
||||
template.location.url(),
|
||||
hasattr(template, 'markdown') and template.markdown is not None
|
||||
))
|
||||
|
||||
components = [
|
||||
component.location.url()
|
||||
for component
|
||||
@@ -219,7 +237,7 @@ def edit_unit(request, location):
|
||||
'subsection': containing_subsection,
|
||||
'release_date': get_default_time_display(containing_subsection.lms.start) if containing_subsection.lms.start is not None else None,
|
||||
'section': containing_section,
|
||||
'create_new_unit_template': Location('i4x', 'edx', 'templates', 'vertical', 'Empty'),
|
||||
'new_unit_category': 'vertical',
|
||||
'unit_state': unit_state,
|
||||
'published_date': get_default_time_display(item.cms.published_date) if item.cms.published_date is not None else None
|
||||
})
|
||||
@@ -227,6 +245,7 @@ def edit_unit(request, location):
|
||||
|
||||
@expect_json
|
||||
@login_required
|
||||
@require_http_methods(("GET", "POST", "PUT"))
|
||||
@ensure_csrf_cookie
|
||||
def assignment_type_update(request, org, course, category, name):
|
||||
'''
|
||||
@@ -238,7 +257,7 @@ def assignment_type_update(request, org, course, category, name):
|
||||
|
||||
if request.method == 'GET':
|
||||
return JsonResponse(CourseGradingModel.get_section_grader_type(location))
|
||||
elif request.method == 'POST': # post or put, doesn't matter.
|
||||
elif request.method in ('POST', 'PUT'): # post or put, doesn't matter.
|
||||
return JsonResponse(CourseGradingModel.update_section_grader_type(location, request.POST))
|
||||
|
||||
|
||||
@@ -253,7 +272,7 @@ def create_draft(request):
|
||||
|
||||
# This clones the existing item location to a draft location (the draft is implicit,
|
||||
# because modulestore is a Draft modulestore)
|
||||
modulestore().clone_item(location, location)
|
||||
modulestore().convert_to_draft(location)
|
||||
|
||||
return HttpResponse()
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"""
|
||||
Views related to operations on course objects
|
||||
"""
|
||||
#pylint: disable=W0402
|
||||
import json
|
||||
import random
|
||||
import string
|
||||
import string # pylint: disable=W0402
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django_future.csrf import ensure_csrf_cookie
|
||||
@@ -43,8 +42,8 @@ from .component import (
|
||||
ADVANCED_COMPONENT_POLICY_KEY)
|
||||
|
||||
from django_comment_common.utils import seed_permissions_roles
|
||||
import datetime
|
||||
from django.utils.timezone import UTC
|
||||
|
||||
from xmodule.html_module import AboutDescriptor
|
||||
__all__ = ['course_index', 'create_new_course', 'course_info',
|
||||
'course_info_updates', 'get_course_settings',
|
||||
'course_config_graders_page',
|
||||
@@ -82,10 +81,11 @@ def course_index(request, org, course, name):
|
||||
'sections': sections,
|
||||
'course_graders': json.dumps(CourseGradingModel.fetch(course.location).graders),
|
||||
'parent_location': course.location,
|
||||
'new_section_template': Location('i4x', 'edx', 'templates', 'chapter', 'Empty'),
|
||||
'new_subsection_template': Location('i4x', 'edx', 'templates', 'sequential', 'Empty'), # for now they are the same, but the could be different at some point...
|
||||
'new_section_category': 'chapter',
|
||||
'new_subsection_category': 'sequential',
|
||||
'upload_asset_callback_url': upload_asset_callback_url,
|
||||
'create_new_unit_template': Location('i4x', 'edx', 'templates', 'vertical', 'Empty')
|
||||
'new_unit_category': 'vertical',
|
||||
'category': 'vertical'
|
||||
})
|
||||
|
||||
|
||||
@@ -98,12 +98,6 @@ def create_new_course(request):
|
||||
if not is_user_in_creator_group(request.user):
|
||||
raise PermissionDenied()
|
||||
|
||||
# This logic is repeated in xmodule/modulestore/tests/factories.py
|
||||
# so if you change anything here, you need to also change it there.
|
||||
# TODO: write a test that creates two courses, one with the factory and
|
||||
# the other with this method, then compare them to make sure they are
|
||||
# equivalent.
|
||||
template = Location(request.POST['template'])
|
||||
org = request.POST.get('org')
|
||||
number = request.POST.get('number')
|
||||
display_name = request.POST.get('display_name')
|
||||
@@ -121,29 +115,31 @@ def create_new_course(request):
|
||||
existing_course = modulestore('direct').get_item(dest_location)
|
||||
except ItemNotFoundError:
|
||||
pass
|
||||
|
||||
if existing_course is not None:
|
||||
return JsonResponse({'ErrMsg': 'There is already a course defined with this name.'})
|
||||
|
||||
course_search_location = ['i4x', dest_location.org, dest_location.course, 'course', None]
|
||||
courses = modulestore().get_items(course_search_location)
|
||||
|
||||
if len(courses) > 0:
|
||||
return JsonResponse({'ErrMsg': 'There is already a course defined with the same organization and course number.'})
|
||||
|
||||
new_course = modulestore('direct').clone_item(template, dest_location)
|
||||
# instantiate the CourseDescriptor and then persist it
|
||||
# note: no system to pass
|
||||
if display_name is None:
|
||||
metadata = {}
|
||||
else:
|
||||
metadata = {'display_name': display_name}
|
||||
modulestore('direct').create_and_save_xmodule(dest_location, metadata=metadata)
|
||||
new_course = modulestore('direct').get_item(dest_location)
|
||||
|
||||
# clone a default 'about' module as well
|
||||
|
||||
about_template_location = Location(['i4x', 'edx', 'templates', 'about', 'overview'])
|
||||
dest_about_location = dest_location._replace(category='about', name='overview')
|
||||
modulestore('direct').clone_item(about_template_location, dest_about_location)
|
||||
|
||||
if display_name is not None:
|
||||
new_course.display_name = display_name
|
||||
|
||||
# set a default start date to now
|
||||
new_course.start = datetime.datetime.now(UTC())
|
||||
# clone a default 'about' overview module as well
|
||||
dest_about_location = dest_location.replace(category='about', name='overview')
|
||||
overview_template = AboutDescriptor.get_template('overview.yaml')
|
||||
modulestore('direct').create_and_save_xmodule(
|
||||
dest_about_location,
|
||||
system=new_course.system,
|
||||
definition_data=overview_template.get('data')
|
||||
)
|
||||
|
||||
initialize_course_tabs(new_course)
|
||||
|
||||
@@ -179,6 +175,7 @@ def course_info(request, org, course, name, provided_id=None):
|
||||
|
||||
|
||||
@expect_json
|
||||
@require_http_methods(("GET", "POST", "PUT", "DELETE"))
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def course_info_updates(request, org, course, provided_id=None):
|
||||
@@ -209,7 +206,7 @@ def course_info_updates(request, org, course, provided_id=None):
|
||||
except:
|
||||
return HttpResponseBadRequest("Failed to delete",
|
||||
content_type="text/plain")
|
||||
elif request.method == 'POST':
|
||||
elif request.method in ('POST', 'PUT'): # can be either and sometimes django is rewriting one to the other
|
||||
try:
|
||||
return JsonResponse(update_course_updates(location, request.POST, provided_id))
|
||||
except:
|
||||
@@ -303,7 +300,7 @@ def course_settings_updates(request, org, course, name, section):
|
||||
if request.method == 'GET':
|
||||
# Cannot just do a get w/o knowing the course name :-(
|
||||
return JsonResponse(manager.fetch(Location(['i4x', org, course, 'course', name])), encoder=CourseSettingsEncoder)
|
||||
elif request.method == 'POST': # post or put, doesn't matter.
|
||||
elif request.method in ('POST', 'PUT'): # post or put, doesn't matter.
|
||||
return JsonResponse(manager.update_from_json(request.POST), encoder=CourseSettingsEncoder)
|
||||
|
||||
|
||||
@@ -482,7 +479,7 @@ def textbook_index(request, org, course, name):
|
||||
if request.is_ajax():
|
||||
if request.method == 'GET':
|
||||
return JsonResponse(course_module.pdf_textbooks)
|
||||
elif request.method == 'POST':
|
||||
elif request.method in ('POST', 'PUT'): # can be either and sometimes django is rewriting one to the other
|
||||
try:
|
||||
textbooks = validate_textbooks_json(request.body)
|
||||
except TextbookValidationError as err:
|
||||
@@ -498,6 +495,9 @@ def textbook_index(request, org, course, name):
|
||||
if not any(tab['type'] == 'pdf_textbooks' for tab in course_module.tabs):
|
||||
course_module.tabs.append({"type": "pdf_textbooks"})
|
||||
course_module.pdf_textbooks = textbooks
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
course_module.save()
|
||||
store.update_metadata(course_module.location, own_metadata(course_module))
|
||||
return JsonResponse(course_module.pdf_textbooks)
|
||||
else:
|
||||
@@ -544,6 +544,9 @@ def create_textbook(request, org, course, name):
|
||||
tabs = course_module.tabs
|
||||
tabs.append({"type": "pdf_textbooks"})
|
||||
course_module.tabs = tabs
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
course_module.save()
|
||||
store.update_metadata(course_module.location, own_metadata(course_module))
|
||||
resp = JsonResponse(textbook, status=201)
|
||||
resp["Location"] = reverse("textbook_by_id", kwargs={
|
||||
@@ -577,7 +580,7 @@ def textbook_by_id(request, org, course, name, tid):
|
||||
if not textbook:
|
||||
return JsonResponse(status=404)
|
||||
return JsonResponse(textbook)
|
||||
elif request.method in ('POST', 'PUT'):
|
||||
elif request.method in ('POST', 'PUT'): # can be either and sometimes django is rewriting one to the other
|
||||
try:
|
||||
new_textbook = validate_textbook_json(request.body)
|
||||
except TextbookValidationError as err:
|
||||
@@ -587,10 +590,13 @@ def textbook_by_id(request, org, course, name, tid):
|
||||
i = course_module.pdf_textbooks.index(textbook)
|
||||
new_textbooks = course_module.pdf_textbooks[0:i]
|
||||
new_textbooks.append(new_textbook)
|
||||
new_textbooks.extend(course_module.pdf_textbooks[i+1:])
|
||||
new_textbooks.extend(course_module.pdf_textbooks[i + 1:])
|
||||
course_module.pdf_textbooks = new_textbooks
|
||||
else:
|
||||
course_module.pdf_textbooks.append(new_textbook)
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
course_module.save()
|
||||
store.update_metadata(course_module.location, own_metadata(course_module))
|
||||
return JsonResponse(new_textbook, status=201)
|
||||
elif request.method == 'DELETE':
|
||||
@@ -598,7 +604,8 @@ def textbook_by_id(request, org, course, name, tid):
|
||||
return JsonResponse(status=404)
|
||||
i = course_module.pdf_textbooks.index(textbook)
|
||||
new_textbooks = course_module.pdf_textbooks[0:i]
|
||||
new_textbooks.extend(course_module.pdf_textbooks[i+1:])
|
||||
new_textbooks.extend(course_module.pdf_textbooks[i + 1:])
|
||||
course_module.pdf_textbooks = new_textbooks
|
||||
course_module.save()
|
||||
store.update_metadata(course_module.location, own_metadata(course_module))
|
||||
return JsonResponse()
|
||||
|
||||
@@ -13,16 +13,26 @@ from util.json_request import expect_json
|
||||
from ..utils import get_modulestore
|
||||
from .access import has_access
|
||||
from .requests import _xmodule_recurse
|
||||
from xmodule.x_module import XModuleDescriptor
|
||||
|
||||
__all__ = ['save_item', 'clone_item', 'delete_item']
|
||||
__all__ = ['save_item', 'create_item', 'delete_item']
|
||||
|
||||
# cdodge: these are categories which should not be parented, they are detached from the hierarchy
|
||||
DETACHED_CATEGORIES = ['about', 'static_tab', 'course_info']
|
||||
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
def save_item(request):
|
||||
"""
|
||||
Will carry a json payload with these possible fields
|
||||
:id (required): the id
|
||||
:data (optional): the new value for the data
|
||||
:metadata (optional): new values for the metadata fields.
|
||||
Any whose values are None will be deleted not set to None! Absent ones will be left alone
|
||||
:nullout (optional): which metadata fields to set to None
|
||||
"""
|
||||
# The nullout is a bit of a temporary copout until we can make module_edit.coffee and the metadata editors a
|
||||
# little smarter and able to pass something more akin to {unset: [field, field]}
|
||||
item_location = request.POST['id']
|
||||
|
||||
# check permissions for this user within this course
|
||||
@@ -42,59 +52,98 @@ def save_item(request):
|
||||
children = request.POST['children']
|
||||
store.update_children(item_location, children)
|
||||
|
||||
# cdodge: also commit any metadata which might have been passed along in the
|
||||
# POST from the client, if it is there
|
||||
# NOTE, that the postback is not the complete metadata, as there's system metadata which is
|
||||
# not presented to the end-user for editing. So let's fetch the original and
|
||||
# 'apply' the submitted metadata, so we don't end up deleting system metadata
|
||||
if request.POST.get('metadata') is not None:
|
||||
posted_metadata = request.POST['metadata']
|
||||
# fetch original
|
||||
# cdodge: also commit any metadata which might have been passed along
|
||||
if request.POST.get('nullout') is not None or request.POST.get('metadata') is not None:
|
||||
# the postback is not the complete metadata, as there's system metadata which is
|
||||
# not presented to the end-user for editing. So let's fetch the original and
|
||||
# 'apply' the submitted metadata, so we don't end up deleting system metadata
|
||||
existing_item = modulestore().get_item(item_location)
|
||||
for metadata_key in request.POST.get('nullout', []):
|
||||
# [dhm] see comment on _get_xblock_field
|
||||
_get_xblock_field(existing_item, metadata_key).write_to(existing_item, None)
|
||||
|
||||
# update existing metadata with submitted metadata (which can be partial)
|
||||
# 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():
|
||||
# IMPORTANT NOTE: if the client passed 'null' (None) for a piece of metadata that means 'remove it'. If
|
||||
# the intent is to make it None, use the nullout field
|
||||
for metadata_key, value in request.POST.get('metadata', {}).items():
|
||||
# [dhm] see comment on _get_xblock_field
|
||||
field = _get_xblock_field(existing_item, metadata_key)
|
||||
|
||||
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 existing_item._model_data:
|
||||
del existing_item._model_data[metadata_key]
|
||||
del posted_metadata[metadata_key]
|
||||
if value is None:
|
||||
field.delete_from(existing_item)
|
||||
else:
|
||||
existing_item._model_data[metadata_key] = value
|
||||
|
||||
value = field.from_json(value)
|
||||
field.write_to(existing_item, value)
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
existing_item.save()
|
||||
# commit to datastore
|
||||
# TODO (cpennington): This really shouldn't have to do this much reaching in to get the metadata
|
||||
store.update_metadata(item_location, own_metadata(existing_item))
|
||||
|
||||
return HttpResponse()
|
||||
|
||||
|
||||
# [DHM] A hack until we implement a permanent soln. Proposed perm solution is to make namespace fields also top level
|
||||
# fields in xblocks rather than requiring dereference through namespace but we'll need to consider whether there are
|
||||
# plausible use cases for distinct fields w/ same name in different namespaces on the same blocks.
|
||||
# The idea is that consumers of the xblock, and particularly the web client, shouldn't know about our internal
|
||||
# representation (namespaces as means of decorating all modules).
|
||||
# Given top-level access, the calls can simply be setattr(existing_item, field, value) ...
|
||||
# Really, this method should be elsewhere (e.g., xblock). We also need methods for has_value (v is_default)...
|
||||
def _get_xblock_field(xblock, field_name):
|
||||
"""
|
||||
A temporary function to get the xblock field either from the xblock or one of its namespaces by name.
|
||||
:param xblock:
|
||||
:param field_name:
|
||||
"""
|
||||
def find_field(fields):
|
||||
for field in fields:
|
||||
if field.name == field_name:
|
||||
return field
|
||||
|
||||
found = find_field(xblock.fields)
|
||||
if found:
|
||||
return found
|
||||
for namespace in xblock.namespaces:
|
||||
found = find_field(getattr(xblock, namespace).fields)
|
||||
if found:
|
||||
return found
|
||||
|
||||
|
||||
@login_required
|
||||
@expect_json
|
||||
def clone_item(request):
|
||||
def create_item(request):
|
||||
parent_location = Location(request.POST['parent_location'])
|
||||
template = Location(request.POST['template'])
|
||||
category = request.POST['category']
|
||||
|
||||
display_name = request.POST.get('display_name')
|
||||
|
||||
if not has_access(request.user, parent_location):
|
||||
raise PermissionDenied()
|
||||
|
||||
parent = get_modulestore(template).get_item(parent_location)
|
||||
dest_location = parent_location._replace(category=template.category, name=uuid4().hex)
|
||||
parent = get_modulestore(category).get_item(parent_location)
|
||||
dest_location = parent_location.replace(category=category, name=uuid4().hex)
|
||||
|
||||
new_item = get_modulestore(template).clone_item(template, dest_location)
|
||||
# get the metadata, display_name, and definition from the request
|
||||
metadata = {}
|
||||
data = None
|
||||
template_id = request.POST.get('boilerplate')
|
||||
if template_id is not None:
|
||||
clz = XModuleDescriptor.load_class(category)
|
||||
if clz is not None:
|
||||
template = clz.get_template(template_id)
|
||||
if template is not None:
|
||||
metadata = template.get('metadata', {})
|
||||
data = template.get('data')
|
||||
|
||||
# replace the display name with an optional parameter passed in from the caller
|
||||
if display_name is not None:
|
||||
new_item.display_name = display_name
|
||||
metadata['display_name'] = display_name
|
||||
|
||||
get_modulestore(template).update_metadata(new_item.location.url(), own_metadata(new_item))
|
||||
get_modulestore(category).create_and_save_xmodule(dest_location, definition_data=data,
|
||||
metadata=metadata, system=parent.system)
|
||||
|
||||
if new_item.location.category not in DETACHED_CATEGORIES:
|
||||
get_modulestore(parent.location).update_children(parent_location, parent.children + [new_item.location.url()])
|
||||
if category not in DETACHED_CATEGORIES:
|
||||
get_modulestore(parent.location).update_children(parent_location, parent.children + [dest_location.url()])
|
||||
|
||||
return HttpResponse(json.dumps({'id': dest_location.url()}))
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from django.core.urlresolvers import reverse
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from mitxmako.shortcuts import render_to_response
|
||||
|
||||
from xmodule_modifiers import replace_static_urls, wrap_xmodule
|
||||
from xmodule_modifiers import replace_static_urls, wrap_xmodule, save_module # pylint: disable=F0401
|
||||
from xmodule.error_module import ErrorDescriptor
|
||||
from xmodule.errortracker import exc_info_to_str
|
||||
from xmodule.exceptions import NotFoundError, ProcessingError
|
||||
@@ -47,6 +47,8 @@ def preview_dispatch(request, preview_id, location, dispatch=None):
|
||||
# Let the module handle the AJAX
|
||||
try:
|
||||
ajax_return = instance.handle_ajax(dispatch, request.POST)
|
||||
# Save any module data that has changed to the underlying KeyValueStore
|
||||
instance.save()
|
||||
|
||||
except NotFoundError:
|
||||
log.exception("Module indicating to user that request doesn't exist")
|
||||
@@ -166,6 +168,11 @@ def load_preview_module(request, preview_id, descriptor):
|
||||
course_namespace=Location([module.location.tag, module.location.org, module.location.course, None, None])
|
||||
)
|
||||
|
||||
module.get_html = save_module(
|
||||
module.get_html,
|
||||
module
|
||||
)
|
||||
|
||||
return module
|
||||
|
||||
|
||||
|
||||
@@ -76,6 +76,9 @@ def reorder_static_tabs(request):
|
||||
|
||||
# OK, re-assemble the static tabs in the new order
|
||||
course.tabs = reordered_tabs
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
course.save()
|
||||
modulestore('direct').update_metadata(course.location, own_metadata(course))
|
||||
return HttpResponse()
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ def index(request):
|
||||
# filter out courses that we don't have access too
|
||||
def course_filter(course):
|
||||
return (has_access(request.user, course.location)
|
||||
# TODO remove this condition when templates purged from db
|
||||
and course.location.course != 'templates'
|
||||
and course.location.org != ''
|
||||
and course.location.course != ''
|
||||
@@ -34,7 +35,6 @@ def index(request):
|
||||
courses = filter(course_filter, courses)
|
||||
|
||||
return render_to_response('index.html', {
|
||||
'new_course_template': Location('i4x', 'edx', 'templates', 'course', 'Empty'),
|
||||
'courses': [(course.display_name,
|
||||
get_url_reverse('CourseOutline', course),
|
||||
get_lms_link_for_item(course.location, course_id=course.location.course_id))
|
||||
@@ -83,6 +83,9 @@ def add_user(request, location):
|
||||
}
|
||||
return JsonResponse(msg, 400)
|
||||
|
||||
# remove leading/trailing whitespace if necessary
|
||||
email = email.strip()
|
||||
|
||||
# check that logged in user has admin permissions to this course
|
||||
if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME):
|
||||
raise PermissionDenied()
|
||||
|
||||
@@ -122,6 +122,10 @@ class CourseDetails(object):
|
||||
descriptor.enrollment_end = converted
|
||||
|
||||
if dirty:
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
descriptor.save()
|
||||
|
||||
get_modulestore(course_location).update_metadata(course_location, own_metadata(descriptor))
|
||||
|
||||
# NOTE: below auto writes to the db w/o verifying that any of the fields actually changed
|
||||
|
||||
@@ -7,9 +7,12 @@ class CourseGradingModel(object):
|
||||
"""
|
||||
Basically a DAO and Model combo for CRUD operations pertaining to grading policy.
|
||||
"""
|
||||
# Within this class, allow access to protected members of client classes.
|
||||
# This comes up when accessing kvs data and caches during kvs saves and modulestore writes.
|
||||
# pylint: disable=W0212
|
||||
def __init__(self, course_descriptor):
|
||||
self.course_location = course_descriptor.location
|
||||
self.graders = [CourseGradingModel.jsonize_grader(i, grader) for i, grader in enumerate(course_descriptor.raw_grader)] # weights transformed to ints [0..100]
|
||||
self.graders = [CourseGradingModel.jsonize_grader(i, grader) for i, grader in enumerate(course_descriptor.raw_grader)] # weights transformed to ints [0..100]
|
||||
self.grade_cutoffs = course_descriptor.grade_cutoffs
|
||||
self.grace_period = CourseGradingModel.convert_set_grace_period(course_descriptor)
|
||||
|
||||
@@ -81,15 +84,18 @@ class CourseGradingModel(object):
|
||||
Decode the json into CourseGradingModel and save any changes. Returns the modified model.
|
||||
Probably not the usual path for updates as it's too coarse grained.
|
||||
"""
|
||||
course_location = jsondict['course_location']
|
||||
course_location = Location(jsondict['course_location'])
|
||||
descriptor = get_modulestore(course_location).get_item(course_location)
|
||||
|
||||
graders_parsed = [CourseGradingModel.parse_grader(jsonele) for jsonele in jsondict['graders']]
|
||||
|
||||
descriptor.raw_grader = graders_parsed
|
||||
descriptor.grade_cutoffs = jsondict['grade_cutoffs']
|
||||
|
||||
get_modulestore(course_location).update_item(course_location, descriptor._model_data._kvs._data)
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
descriptor.save()
|
||||
get_modulestore(course_location).update_item(course_location, descriptor.xblock_kvs._data)
|
||||
|
||||
CourseGradingModel.update_grace_period_from_json(course_location, jsondict['grace_period'])
|
||||
|
||||
return CourseGradingModel.fetch(course_location)
|
||||
@@ -116,6 +122,9 @@ class CourseGradingModel(object):
|
||||
else:
|
||||
descriptor.raw_grader.append(grader)
|
||||
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
descriptor.save()
|
||||
get_modulestore(course_location).update_item(course_location, descriptor._model_data._kvs._data)
|
||||
|
||||
return CourseGradingModel.jsonize_grader(index, descriptor.raw_grader[index])
|
||||
@@ -131,6 +140,10 @@ class CourseGradingModel(object):
|
||||
|
||||
descriptor = get_modulestore(course_location).get_item(course_location)
|
||||
descriptor.grade_cutoffs = cutoffs
|
||||
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
descriptor.save()
|
||||
get_modulestore(course_location).update_item(course_location, descriptor._model_data._kvs._data)
|
||||
|
||||
return cutoffs
|
||||
@@ -156,6 +169,10 @@ class CourseGradingModel(object):
|
||||
|
||||
descriptor = get_modulestore(course_location).get_item(course_location)
|
||||
descriptor.lms.graceperiod = grace_timedelta
|
||||
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
descriptor.save()
|
||||
get_modulestore(course_location).update_metadata(course_location, descriptor._model_data._kvs._metadata)
|
||||
|
||||
@staticmethod
|
||||
@@ -172,23 +189,12 @@ class CourseGradingModel(object):
|
||||
del descriptor.raw_grader[index]
|
||||
# force propagation to definition
|
||||
descriptor.raw_grader = descriptor.raw_grader
|
||||
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
descriptor.save()
|
||||
get_modulestore(course_location).update_item(course_location, descriptor._model_data._kvs._data)
|
||||
|
||||
# NOTE cannot delete cutoffs. May be useful to reset
|
||||
@staticmethod
|
||||
def delete_cutoffs(course_location, cutoffs):
|
||||
"""
|
||||
Resets the cutoffs to the defaults
|
||||
"""
|
||||
if not isinstance(course_location, Location):
|
||||
course_location = Location(course_location)
|
||||
|
||||
descriptor = get_modulestore(course_location).get_item(course_location)
|
||||
descriptor.grade_cutoffs = descriptor.defaut_grading_policy['GRADE_CUTOFFS']
|
||||
get_modulestore(course_location).update_item(course_location, descriptor._model_data._kvs._data)
|
||||
|
||||
return descriptor.grade_cutoffs
|
||||
|
||||
@staticmethod
|
||||
def delete_grace_period(course_location):
|
||||
"""
|
||||
@@ -199,6 +205,10 @@ class CourseGradingModel(object):
|
||||
|
||||
descriptor = get_modulestore(course_location).get_item(course_location)
|
||||
del descriptor.lms.graceperiod
|
||||
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
descriptor.save()
|
||||
get_modulestore(course_location).update_metadata(course_location, descriptor._model_data._kvs._metadata)
|
||||
|
||||
@staticmethod
|
||||
@@ -209,7 +219,7 @@ class CourseGradingModel(object):
|
||||
descriptor = get_modulestore(location).get_item(location)
|
||||
return {"graderType": descriptor.lms.format if descriptor.lms.format is not None else 'Not Graded',
|
||||
"location": location,
|
||||
"id": 99 # just an arbitrary value to
|
||||
"id": 99 # just an arbitrary value to
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@@ -225,6 +235,9 @@ class CourseGradingModel(object):
|
||||
del descriptor.lms.format
|
||||
del descriptor.lms.graded
|
||||
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
descriptor.save()
|
||||
get_modulestore(location).update_metadata(location, descriptor._model_data._kvs._metadata)
|
||||
|
||||
@staticmethod
|
||||
@@ -232,7 +245,7 @@ class CourseGradingModel(object):
|
||||
# 5 hours 59 minutes 59 seconds => converted to iso format
|
||||
rawgrace = descriptor.lms.graceperiod
|
||||
if rawgrace:
|
||||
hours_from_days = rawgrace.days*24
|
||||
hours_from_days = rawgrace.days * 24
|
||||
seconds = rawgrace.seconds
|
||||
hours_from_seconds = int(seconds / 3600)
|
||||
hours = hours_from_days + hours_from_seconds
|
||||
|
||||
@@ -76,6 +76,9 @@ class CourseMetadata(object):
|
||||
setattr(descriptor.lms, key, value)
|
||||
|
||||
if dirty:
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
descriptor.save()
|
||||
get_modulestore(course_location).update_metadata(course_location,
|
||||
own_metadata(descriptor))
|
||||
|
||||
@@ -97,6 +100,10 @@ class CourseMetadata(object):
|
||||
elif hasattr(descriptor.lms, key):
|
||||
delattr(descriptor.lms, key)
|
||||
|
||||
# Save the data that we've just changed to the underlying
|
||||
# MongoKeyValueStore before we update the mongo datastore.
|
||||
descriptor.save()
|
||||
|
||||
get_modulestore(course_location).update_metadata(course_location,
|
||||
own_metadata(descriptor))
|
||||
|
||||
|
||||
@@ -92,6 +92,7 @@ LOG_DIR = ENV_TOKENS['LOG_DIR']
|
||||
CACHES = ENV_TOKENS['CACHES']
|
||||
|
||||
SESSION_COOKIE_DOMAIN = ENV_TOKENS.get('SESSION_COOKIE_DOMAIN')
|
||||
SESSION_ENGINE = ENV_TOKENS.get('SESSION_ENGINE', SESSION_ENGINE)
|
||||
|
||||
# allow for environments to specify what cookie name our login subsystem should use
|
||||
# this is to fix a bug regarding simultaneous logins between edx.org and edge.edx.org which can
|
||||
@@ -122,6 +123,10 @@ LOGGING = get_logger_config(LOG_DIR,
|
||||
debug=False,
|
||||
service_variant=SERVICE_VARIANT)
|
||||
|
||||
#theming start:
|
||||
PLATFORM_NAME = ENV_TOKENS.get('PLATFORM_NAME', 'edX')
|
||||
|
||||
|
||||
################ SECURE AUTH ITEMS ###############################
|
||||
# Secret things: passwords, access keys, etc.
|
||||
with open(ENV_ROOT / CONFIG_PREFIX + "auth.json") as auth_file:
|
||||
|
||||
@@ -33,6 +33,10 @@ MODULESTORE = {
|
||||
'direct': {
|
||||
'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore',
|
||||
'OPTIONS': modulestore_options
|
||||
},
|
||||
'split': {
|
||||
'ENGINE': 'xmodule.modulestore.split_mongo.SplitMongoModuleStore',
|
||||
'OPTIONS': modulestore_options
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,10 @@ MODULESTORE = {
|
||||
'draft': {
|
||||
'ENGINE': 'xmodule.modulestore.draft.DraftModuleStore',
|
||||
'OPTIONS': MODULESTORE_OPTIONS
|
||||
},
|
||||
'split': {
|
||||
'ENGINE': 'xmodule.modulestore.split_mongo.SplitMongoModuleStore',
|
||||
'OPTIONS': MODULESTORE_OPTIONS
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
from django.core.management import execute_manager
|
||||
import imp
|
||||
try:
|
||||
imp.find_module('settings') # Assumed to be in the same directory.
|
||||
except ImportError:
|
||||
import sys
|
||||
sys.stderr.write("Error: Can't find the file 'settings.py' in the directory containing %r. "
|
||||
"It appears you've customized things.\nYou'll have to run django-admin.py, "
|
||||
"passing it your settings module.\n" % __file__)
|
||||
sys.exit(1)
|
||||
|
||||
import settings
|
||||
|
||||
if __name__ == "__main__":
|
||||
execute_manager(settings)
|
||||
@@ -40,17 +40,30 @@ describe "Course Overview", ->
|
||||
</div>
|
||||
"""#"
|
||||
|
||||
appendSetFixtures """
|
||||
<section class="courseware-section branch" data-id="a-location-goes-here">
|
||||
<li class="branch collapsed id-holder" data-id="an-id-goes-here">
|
||||
<a href="#" class="delete-section-button"></a>
|
||||
</li>
|
||||
</section>
|
||||
"""#"
|
||||
|
||||
spyOn(window, 'saveSetSectionScheduleDate').andCallThrough()
|
||||
# Have to do this here, as it normally gets bound in document.ready()
|
||||
$('a.save-button').click(saveSetSectionScheduleDate)
|
||||
$('a.delete-section-button').click(deleteSection)
|
||||
|
||||
@notificationSpy = spyOn(CMS.Views.Notification.Mini.prototype, 'show').andCallThrough()
|
||||
window.analytics = jasmine.createSpyObj('analytics', ['track'])
|
||||
window.course_location_analytics = jasmine.createSpy()
|
||||
sinon.useFakeXMLHttpRequest()
|
||||
@xhr = sinon.useFakeXMLHttpRequest()
|
||||
requests = @requests = []
|
||||
@xhr.onCreate = (req) -> requests.push(req)
|
||||
|
||||
afterEach ->
|
||||
delete window.analytics
|
||||
delete window.course_location_analytics
|
||||
@notificationSpy.reset()
|
||||
|
||||
it "should save model when save is clicked", ->
|
||||
$('a.edit-button').click()
|
||||
@@ -61,3 +74,21 @@ describe "Course Overview", ->
|
||||
$('a.edit-button').click()
|
||||
$('a.save-button').click()
|
||||
expect(@notificationSpy).toHaveBeenCalled()
|
||||
|
||||
it "should delete model when delete is clicked", ->
|
||||
deleteSpy = spyOn(window, '_deleteItem').andCallThrough()
|
||||
$('a.delete-section-button').click()
|
||||
$('a.action-primary').click()
|
||||
expect(deleteSpy).toHaveBeenCalled()
|
||||
expect(@requests[0].url).toEqual('/delete_item')
|
||||
|
||||
it "should not delete model when cancel is clicked", ->
|
||||
deleteSpy = spyOn(window, '_deleteItem').andCallThrough()
|
||||
$('a.delete-section-button').click()
|
||||
$('a.action-secondary').click()
|
||||
expect(@requests.length).toEqual(0)
|
||||
|
||||
it "should show a confirmation on delete", ->
|
||||
$('a.delete-section-button').click()
|
||||
$('a.action-primary').click()
|
||||
expect(@notificationSpy).toHaveBeenCalled()
|
||||
|
||||
@@ -56,14 +56,15 @@ class CMS.Views.ModuleEdit extends Backbone.View
|
||||
changedMetadata: ->
|
||||
return _.extend(@metadataEditor.getModifiedMetadataValues(), @customMetadata())
|
||||
|
||||
cloneTemplate: (parent, template) ->
|
||||
$.post("/clone_item", {
|
||||
parent_location: parent
|
||||
template: template
|
||||
}, (data) =>
|
||||
@model.set(id: data.id)
|
||||
@$el.data('id', data.id)
|
||||
@render()
|
||||
createItem: (parent, payload) ->
|
||||
payload.parent_location = parent
|
||||
$.post(
|
||||
"/create_item"
|
||||
payload
|
||||
(data) =>
|
||||
@model.set(id: data.id)
|
||||
@$el.data('id', data.id)
|
||||
@render()
|
||||
)
|
||||
|
||||
render: ->
|
||||
@@ -83,11 +84,15 @@ class CMS.Views.ModuleEdit extends Backbone.View
|
||||
|
||||
data.metadata = _.extend(data.metadata || {}, @changedMetadata())
|
||||
@hideModal()
|
||||
saving = new CMS.Views.Notification.Mini
|
||||
title: gettext('Saving') + '…'
|
||||
saving.show()
|
||||
@model.save(data).done( =>
|
||||
# # showToastMessage("Your changes have been saved.", null, 3)
|
||||
@module = null
|
||||
@render()
|
||||
@$el.removeClass('editing')
|
||||
saving.hide()
|
||||
).fail( ->
|
||||
showToastMessage(gettext("There was an error saving your changes. Please try again."), null, 3)
|
||||
)
|
||||
|
||||
@@ -55,9 +55,9 @@ class CMS.Views.TabsEdit extends Backbone.View
|
||||
editor.$el.removeClass('new')
|
||||
, 500)
|
||||
|
||||
editor.cloneTemplate(
|
||||
editor.createItem(
|
||||
@model.get('id'),
|
||||
'i4x://edx/templates/static_tab/Empty'
|
||||
{category: 'static_tab'}
|
||||
)
|
||||
|
||||
analytics.track "Added Static Page",
|
||||
|
||||
@@ -67,8 +67,8 @@ class CMS.Views.UnitEdit extends Backbone.View
|
||||
type = $(event.currentTarget).data('type')
|
||||
@$newComponentTypePicker.slideUp(250)
|
||||
@$(".new-component-#{type}").slideDown(250)
|
||||
$('html, body').animate({
|
||||
scrollTop: @$(".new-component-#{type}").offset().top
|
||||
$('html, body').animate({
|
||||
scrollTop: @$(".new-component-#{type}").offset().top
|
||||
}, 500)
|
||||
|
||||
closeNewComponent: (event) =>
|
||||
@@ -89,9 +89,9 @@ class CMS.Views.UnitEdit extends Backbone.View
|
||||
|
||||
@$newComponentItem.before(editor.$el)
|
||||
|
||||
editor.cloneTemplate(
|
||||
editor.createItem(
|
||||
@$el.data('id'),
|
||||
$(event.currentTarget).data('location')
|
||||
$(event.currentTarget).data()
|
||||
)
|
||||
|
||||
analytics.track "Added a Component",
|
||||
@@ -115,27 +115,43 @@ class CMS.Views.UnitEdit extends Backbone.View
|
||||
@model.save()
|
||||
|
||||
deleteComponent: (event) =>
|
||||
if not confirm 'Are you sure you want to delete this component? This action cannot be undone.'
|
||||
return
|
||||
$component = $(event.currentTarget).parents('.component')
|
||||
$.post('/delete_item', {
|
||||
id: $component.data('id')
|
||||
}, =>
|
||||
analytics.track "Deleted a Component",
|
||||
course: course_location_analytics
|
||||
unit_id: unit_location_analytics
|
||||
id: $component.data('id')
|
||||
msg = new CMS.Views.Prompt.Warning(
|
||||
title: gettext('Delete this component?'),
|
||||
message: gettext('Deleting this component is permanent and cannot be undone.'),
|
||||
actions:
|
||||
primary:
|
||||
text: gettext('Yes, delete this component'),
|
||||
click: (view) =>
|
||||
view.hide()
|
||||
deleting = new CMS.Views.Notification.Mini
|
||||
title: gettext('Deleting') + '…',
|
||||
deleting.show()
|
||||
$component = $(event.currentTarget).parents('.component')
|
||||
$.post('/delete_item', {
|
||||
id: $component.data('id')
|
||||
}, =>
|
||||
deleting.hide()
|
||||
analytics.track "Deleted a Component",
|
||||
course: course_location_analytics
|
||||
unit_id: unit_location_analytics
|
||||
id: $component.data('id')
|
||||
|
||||
$component.remove()
|
||||
# b/c we don't vigilantly keep children up to date
|
||||
# get rid of it before it hurts someone
|
||||
# sorry for the js, i couldn't figure out the coffee equivalent
|
||||
`_this.model.save({children: _this.components()},
|
||||
{success: function(model) {
|
||||
model.unset('children');
|
||||
}}
|
||||
);`
|
||||
$component.remove()
|
||||
# b/c we don't vigilantly keep children up to date
|
||||
# get rid of it before it hurts someone
|
||||
# sorry for the js, i couldn't figure out the coffee equivalent
|
||||
`_this.model.save({children: _this.components()},
|
||||
{success: function(model) {
|
||||
model.unset('children');
|
||||
}}
|
||||
);`
|
||||
)
|
||||
secondary:
|
||||
text: gettext('Cancel'),
|
||||
click: (view) ->
|
||||
view.hide()
|
||||
)
|
||||
msg.show()
|
||||
|
||||
deleteDraft: (event) ->
|
||||
@wait(true)
|
||||
@@ -236,7 +252,7 @@ class CMS.Views.UnitEdit.NameEdit extends Backbone.View
|
||||
class CMS.Views.UnitEdit.LocationState extends Backbone.View
|
||||
initialize: =>
|
||||
@model.on('change:state', @render)
|
||||
|
||||
|
||||
render: =>
|
||||
@$el.toggleClass("#{@model.previous('state')}-item #{@model.get('state')}-item")
|
||||
|
||||
|
||||
@@ -253,17 +253,13 @@ function syncReleaseDate(e) {
|
||||
}
|
||||
|
||||
function getEdxTimeFromDateTimeVals(date_val, time_val) {
|
||||
var edxTimeStr = null;
|
||||
|
||||
if (date_val != '') {
|
||||
if (time_val == '') time_val = '00:00';
|
||||
|
||||
// Note, we are using date.js utility which has better parsing abilities than the built in JS date parsing
|
||||
var date = Date.parse(date_val + " " + time_val);
|
||||
edxTimeStr = date.toString('yyyy-MM-ddTHH:mm');
|
||||
return new Date(date_val + " " + time_val + "Z");
|
||||
}
|
||||
|
||||
return edxTimeStr;
|
||||
else return null;
|
||||
}
|
||||
|
||||
function getEdxTimeFromDateTimeInputs(date_id, time_id) {
|
||||
@@ -338,7 +334,7 @@ function createNewUnit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
var parent = $(this).data('parent');
|
||||
var template = $(this).data('template');
|
||||
var category = $(this).data('category');
|
||||
|
||||
analytics.track('Created a Unit', {
|
||||
'course': course_location_analytics,
|
||||
@@ -346,9 +342,9 @@ function createNewUnit(e) {
|
||||
});
|
||||
|
||||
|
||||
$.post('/clone_item', {
|
||||
$.post('/create_item', {
|
||||
'parent_location': parent,
|
||||
'template': template,
|
||||
'category': category,
|
||||
'display_name': 'New Unit'
|
||||
},
|
||||
|
||||
@@ -360,39 +356,61 @@ function createNewUnit(e) {
|
||||
|
||||
function deleteUnit(e) {
|
||||
e.preventDefault();
|
||||
_deleteItem($(this).parents('li.leaf'));
|
||||
_deleteItem($(this).parents('li.leaf'), 'Unit');
|
||||
}
|
||||
|
||||
function deleteSubsection(e) {
|
||||
e.preventDefault();
|
||||
_deleteItem($(this).parents('li.branch'));
|
||||
_deleteItem($(this).parents('li.branch'), 'Subsection');
|
||||
}
|
||||
|
||||
function deleteSection(e) {
|
||||
e.preventDefault();
|
||||
_deleteItem($(this).parents('section.branch'));
|
||||
_deleteItem($(this).parents('section.branch'), 'Section');
|
||||
}
|
||||
|
||||
function _deleteItem($el) {
|
||||
if (!confirm(gettext('Are you sure you wish to delete this item. It cannot be reversed!'))) return;
|
||||
function _deleteItem($el, type) {
|
||||
var confirm = new CMS.Views.Prompt.Warning({
|
||||
title: gettext('Delete this ' + type + '?'),
|
||||
message: gettext('Deleting this ' + type + ' is permanent and cannot be undone.'),
|
||||
actions: {
|
||||
primary: {
|
||||
text: gettext('Yes, delete this ' + type),
|
||||
click: function(view) {
|
||||
view.hide();
|
||||
|
||||
var id = $el.data('id');
|
||||
var id = $el.data('id');
|
||||
|
||||
analytics.track('Deleted an Item', {
|
||||
'course': course_location_analytics,
|
||||
'id': id
|
||||
});
|
||||
|
||||
|
||||
$.post('/delete_item', {
|
||||
'id': id,
|
||||
'delete_children': true,
|
||||
'delete_all_versions': true
|
||||
},
|
||||
|
||||
function(data) {
|
||||
$el.remove();
|
||||
analytics.track('Deleted an Item', {
|
||||
'course': course_location_analytics,
|
||||
'id': id
|
||||
});
|
||||
|
||||
var deleting = new CMS.Views.Notification.Mini({
|
||||
title: gettext('Deleting') + '…'
|
||||
});
|
||||
deleting.show();
|
||||
|
||||
$.post('/delete_item',
|
||||
{'id': id,
|
||||
'delete_children': true,
|
||||
'delete_all_versions': true},
|
||||
function(data) {
|
||||
$el.remove();
|
||||
deleting.hide();
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
secondary: {
|
||||
text: gettext('Cancel'),
|
||||
click: function(view) {
|
||||
view.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
confirm.show();
|
||||
}
|
||||
|
||||
function markAsLoaded() {
|
||||
@@ -551,7 +569,7 @@ function saveNewSection(e) {
|
||||
|
||||
var $saveButton = $(this).find('.new-section-name-save');
|
||||
var parent = $saveButton.data('parent');
|
||||
var template = $saveButton.data('template');
|
||||
var category = $saveButton.data('category');
|
||||
var display_name = $(this).find('.new-section-name').val();
|
||||
|
||||
analytics.track('Created a Section', {
|
||||
@@ -559,9 +577,9 @@ function saveNewSection(e) {
|
||||
'display_name': display_name
|
||||
});
|
||||
|
||||
$.post('/clone_item', {
|
||||
$.post('/create_item', {
|
||||
'parent_location': parent,
|
||||
'template': template,
|
||||
'category': category,
|
||||
'display_name': display_name,
|
||||
},
|
||||
|
||||
@@ -595,7 +613,6 @@ function saveNewCourse(e) {
|
||||
e.preventDefault();
|
||||
|
||||
var $newCourse = $(this).closest('.new-course');
|
||||
var template = $(this).find('.new-course-save').data('template');
|
||||
var org = $newCourse.find('.new-course-org').val();
|
||||
var number = $newCourse.find('.new-course-number').val();
|
||||
var display_name = $newCourse.find('.new-course-name').val();
|
||||
@@ -612,7 +629,6 @@ function saveNewCourse(e) {
|
||||
});
|
||||
|
||||
$.post('/create_new_course', {
|
||||
'template': template,
|
||||
'org': org,
|
||||
'number': number,
|
||||
'display_name': display_name
|
||||
@@ -646,7 +662,7 @@ function addNewSubsection(e) {
|
||||
var parent = $(this).parents("section.branch").data("id");
|
||||
|
||||
$saveButton.data('parent', parent);
|
||||
$saveButton.data('template', $(this).data('template'));
|
||||
$saveButton.data('category', $(this).data('category'));
|
||||
|
||||
$newSubsection.find('.new-subsection-form').bind('submit', saveNewSubsection);
|
||||
$cancelButton.bind('click', cancelNewSubsection);
|
||||
@@ -659,7 +675,7 @@ function saveNewSubsection(e) {
|
||||
e.preventDefault();
|
||||
|
||||
var parent = $(this).find('.new-subsection-name-save').data('parent');
|
||||
var template = $(this).find('.new-subsection-name-save').data('template');
|
||||
var category = $(this).find('.new-subsection-name-save').data('category');
|
||||
var display_name = $(this).find('.new-subsection-name-input').val();
|
||||
|
||||
analytics.track('Created a Subsection', {
|
||||
@@ -668,9 +684,9 @@ function saveNewSubsection(e) {
|
||||
});
|
||||
|
||||
|
||||
$.post('/clone_item', {
|
||||
$.post('/create_item', {
|
||||
'parent_location': parent,
|
||||
'template': template,
|
||||
'category': category,
|
||||
'display_name': display_name
|
||||
},
|
||||
|
||||
@@ -734,7 +750,7 @@ function saveSetSectionScheduleDate(e) {
|
||||
var $thisSection = $('.courseware-section[data-id="' + id + '"]');
|
||||
var html = _.template(
|
||||
'<span class="published-status">' +
|
||||
'<strong>' + gettext("Will Release: ") + '</strong>' +
|
||||
'<strong>' + gettext("Will Release:") + ' </strong>' +
|
||||
gettext("<%= date %> at <%= time %> UTC") +
|
||||
'</span>' +
|
||||
'<a href="#" class="edit-button" data-date="<%= date %>" data-time="<%= time %>" data-id="<%= id %>">' +
|
||||
|
||||
@@ -9,7 +9,7 @@ function removeAsset(e){
|
||||
e.preventDefault();
|
||||
|
||||
var that = this;
|
||||
var msg = new CMS.Views.Prompt.Confirmation({
|
||||
var msg = new CMS.Views.Prompt.Warning({
|
||||
title: gettext("Delete File Confirmation"),
|
||||
message: gettext("Are you sure you wish to delete this item. It cannot be reversed!\n\nAlso any content that links/refers to this item will no longer work (e.g. broken images and/or links)"),
|
||||
actions: {
|
||||
|
||||
@@ -81,9 +81,18 @@ CMS.Views.OverviewAssignmentGrader = Backbone.View.extend({
|
||||
|
||||
this.removeMenu(e);
|
||||
|
||||
var saving = new CMS.Views.Notification.Mini({
|
||||
title: gettext('Saving') + '…'
|
||||
});
|
||||
saving.show();
|
||||
|
||||
// TODO I'm not happy with this string fetch via the html for what should be an id. I'd rather use the id attr
|
||||
// of the CourseGradingPolicy model or null for Not Graded (NOTE, change template's if check for is-selected accordingly)
|
||||
this.assignmentGrade.save('graderType', $(e.target).text());
|
||||
this.assignmentGrade.save(
|
||||
'graderType',
|
||||
$(e.target).text(),
|
||||
{success: function () { saving.hide(); }}
|
||||
);
|
||||
|
||||
this.render();
|
||||
}
|
||||
|
||||
@@ -107,6 +107,7 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({
|
||||
// to pick up when the date is typed directly in the field.
|
||||
datefield.change(setfield);
|
||||
timefield.on('changeTime', setfield);
|
||||
timefield.on('input', setfield);
|
||||
|
||||
datefield.datepicker('setDate', this.model.get(fieldName));
|
||||
// timepicker doesn't let us set null, so check that we have a time
|
||||
|
||||
@@ -241,7 +241,7 @@ CMS.Views.EditChapter = Backbone.View.extend({
|
||||
asset_path: this.$("input.chapter-asset-path").val()
|
||||
});
|
||||
var msg = new CMS.Models.FileUpload({
|
||||
title: _.template(gettext("Upload a new asset to “<%= name %>”"),
|
||||
title: _.template(gettext("Upload a new PDF to “<%= name %>”"),
|
||||
{name: section.escape('name')}),
|
||||
message: "Files must be in PDF format."
|
||||
});
|
||||
|
||||
@@ -71,8 +71,13 @@ body.index {
|
||||
color: $white;
|
||||
}
|
||||
|
||||
.wrapper-text-welcome, .logo {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-weight: 600;
|
||||
margin-left: ($baseline/2);
|
||||
}
|
||||
|
||||
.tagline {
|
||||
|
||||
@@ -747,6 +747,7 @@ body.unit {
|
||||
|
||||
// Unit Page Sidebar
|
||||
.unit-settings {
|
||||
|
||||
.window-contents {
|
||||
padding: $baseline/2 $baseline;
|
||||
}
|
||||
@@ -854,6 +855,24 @@ body.unit {
|
||||
}
|
||||
|
||||
.unit-location {
|
||||
|
||||
// unit id
|
||||
.wrapper-unit-id {
|
||||
|
||||
.unit-id {
|
||||
|
||||
.label {
|
||||
@extend .t-title7;
|
||||
margin-bottom: ($baseline/4);
|
||||
color: $gray-d1;
|
||||
}
|
||||
|
||||
.value {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.url {
|
||||
box-shadow: none;
|
||||
width: 100%;
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
<%inherit file="base.html" />
|
||||
<%! from django.core.urlresolvers import reverse %>
|
||||
<%block name="title">Editing Static Page</%block>
|
||||
<%block name="bodyclass">is-signedin course pages edit-static-page</%block>
|
||||
|
||||
<%block name="content">
|
||||
<div class="main-wrapper">
|
||||
<div class="inner-wrapper">
|
||||
<div class="main-column">
|
||||
<article class="static-page-details">
|
||||
<div class="row">
|
||||
<label>Display Name:</label>
|
||||
<input type="text" value="Syllabus" class="page-display-name-input" data-metadata-name="display_name"/>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Page Content:</label>
|
||||
<textarea class="page-contents"></textarea>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div class="sidebar">
|
||||
<div class="unit-settings window">
|
||||
<h4 class="header">Page Settings</h4>
|
||||
<div class="window-contents">
|
||||
<div class="row visibility">
|
||||
<label class="inline-label">Visibility:</label>
|
||||
<select>
|
||||
<option>Public</option>
|
||||
<option>Private</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="row unit-actions">
|
||||
<a href="#" class="save-button">Save</a>
|
||||
<a href="#" target="_blank" class="preview-button">Preview</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</%block>
|
||||
@@ -1,13 +0,0 @@
|
||||
<section class='editable-preview'>
|
||||
${content}
|
||||
<div class="component-actions">
|
||||
<a href="#" class="edit-button"><span class="edit-icon white"></span>Edit</a>
|
||||
<a href="#" class="delete-button"><span class="delete-icon white"></span>Delete</a>
|
||||
</div>
|
||||
<a data-tooltip="Drag to reorder" href="#" class="drag-handle"></a>
|
||||
<div class="component-editor">
|
||||
<h5>Edit Video Component</h5>
|
||||
<textarea class="component-source"><video youtube="1.50:q1xkuPsOY6Q,1.25:9WOY2dHz5i4,1.0:4rpg8Bq6hb4,0.75:KLim9Xkp7IY"/></textarea>
|
||||
<a href="#" class="save-button">Save</a><a href="#" class="cancel-button">Cancel</a>
|
||||
</div>
|
||||
<section>
|
||||
@@ -11,7 +11,7 @@
|
||||
<section class="content content-header">
|
||||
<header>
|
||||
## "edX Studio" should not be translated
|
||||
<h1>${_('Welcome to')}<span class="logo">edX Studio</span></h1>
|
||||
<h1><span class="wrapper-text-welcome">${_('Welcome to')}</span><span class="logo">edX Studio</span></h1>
|
||||
<p class="tagline">${_("Studio helps manage your courses online, so you can focus on teaching them")}</p>
|
||||
</header>
|
||||
</section>
|
||||
|
||||
@@ -25,8 +25,8 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<input type="submit" value="${_('Save')}" class="new-course-save" data-template="${new_course_template}" />
|
||||
<input type="button" value="${_('Cancel')}" class="new-course-cancel" />
|
||||
<input type="submit" value="${_('Save')}" class="new-course-save"/>
|
||||
<input type="button" value="${_('Cancel')}" class="new-course-cancel" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,6 @@
|
||||
<label for="chapter<%= order %>-asset-path"><%= gettext("Chapter Asset") %></label>
|
||||
<input id="chapter<%= order %>-asset-path" name="chapter<%= order %>-asset-path" class="chapter-asset-path" placeholder="<%= _.str.sprintf(gettext("path/to/introductionToCookieBaking-CH%d.pdf"), order) %>" value="<%= asset_path %>" type="text">
|
||||
<span class="tip tip-stacked"><%= gettext("upload a PDF file or provide the path to a Studio asset file") %></span>
|
||||
<button class="action action-upload"><%= gettext("Upload Asset") %></button>
|
||||
<button class="action action-upload"><%= gettext("Upload PDF") %></button>
|
||||
</div>
|
||||
<a href="" class="action action-close"><i class="icon-remove-sign"></i> <span class="sr"><%= gettext("delete chapter") %></span></a>
|
||||
|
||||
@@ -66,7 +66,8 @@
|
||||
<h3 class="section-name">
|
||||
<form class="section-name-form">
|
||||
<input type="text" value="${_('New Section Name')}" class="new-section-name" />
|
||||
<input type="submit" class="new-section-name-save" data-parent="${parent_location}" data-template="${new_section_template}" value="${_('Save')}" />
|
||||
<input type="submit" class="new-section-name-save" data-parent="${parent_location}"
|
||||
data-category="${new_section_category}" value="${_('Save')}" />
|
||||
<input type="button" class="new-section-name-cancel" value="${_('Cancel')}" /></h3>
|
||||
</form>
|
||||
</div>
|
||||
@@ -83,8 +84,9 @@
|
||||
<span class="section-name-span">Click here to set the section name</span>
|
||||
<form class="section-name-form">
|
||||
<input type="text" value="${_('New Section Name')}" class="new-section-name" />
|
||||
<input type="submit" class="new-section-name-save" data-parent="${parent_location}" data-template="${new_section_template}" value="${_('Save')}" />
|
||||
<input type="button" class="new-section-name-cancel" value="${_('Cancel')}" /></h3>
|
||||
<input type="submit" class="new-section-name-save" data-parent="${parent_location}"
|
||||
data-category="${new_section_category}" value="${_('Save')}" />
|
||||
<input type="button" class="new-section-name-cancel" value="$(_('Cancel')}" /></h3>
|
||||
</form>
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
@@ -181,7 +183,7 @@
|
||||
</header>
|
||||
<div class="subsection-list">
|
||||
<div class="list-header">
|
||||
<a href="#" class="new-subsection-item" data-template="${new_subsection_template}">
|
||||
<a href="#" class="new-subsection-item" data-category="${new_subsection_category}">
|
||||
<span class="new-folder-icon"></span>${_("New Subsection")}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -59,8 +59,8 @@
|
||||
% if type == 'advanced' or len(templates) > 1:
|
||||
<a href="#" class="multiple-templates" data-type="${type}">
|
||||
% else:
|
||||
% for __, location, __ in templates:
|
||||
<a href="#" class="single-template" data-type="${type}" data-location="${location}">
|
||||
% for __, category, __, __ in templates:
|
||||
<a href="#" class="single-template" data-type="${type}" data-category="${category}">
|
||||
% endfor
|
||||
% endif
|
||||
<span class="large-template-icon large-${type}-icon"></span>
|
||||
@@ -74,49 +74,60 @@
|
||||
% if len(templates) > 1 or type == 'advanced':
|
||||
<div class="new-component-templates new-component-${type}">
|
||||
% if type == "problem":
|
||||
<div class="tab-group tabs">
|
||||
<ul class="problem-type-tabs nav-tabs">
|
||||
<li class="current">
|
||||
<a class="link-tab" href="#tab1">${_("Common Problem Types")}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="link-tab" href="#tab2">${_("Advanced")}</a>
|
||||
</li>
|
||||
</ul>
|
||||
% endif
|
||||
<div class="tab current" id="tab1">
|
||||
<ul class="new-component-template">
|
||||
% for name, location, has_markdown in templates:
|
||||
% if has_markdown or type != "problem":
|
||||
<li class="editor-md">
|
||||
<a href="#" id="${location}" data-location="${location}">
|
||||
<span class="name"> ${name}</span>
|
||||
</a>
|
||||
</li>
|
||||
% endif
|
||||
|
||||
%endfor
|
||||
<div class="tab-group tabs">
|
||||
<ul class="problem-type-tabs nav-tabs">
|
||||
<li class="current">
|
||||
<a class="link-tab" href="#tab1">${_("Common Problem Types")}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="link-tab" href="#tab2">${_("Advanced")}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
% if type == "problem":
|
||||
<div class="tab" id="tab2">
|
||||
<ul class="new-component-template">
|
||||
% for name, location, has_markdown in templates:
|
||||
% if not has_markdown:
|
||||
<li class="editor-manual">
|
||||
<a href="#" id="${location}" data-location="${location}">
|
||||
<span class="name"> ${name}</span>
|
||||
</a>
|
||||
</li>
|
||||
% endif
|
||||
% endfor
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
% endif
|
||||
<a href="#" class="cancel-button">${_("Cancel")}</a>
|
||||
</div>
|
||||
% endif
|
||||
% endif
|
||||
<div class="tab current" id="tab1">
|
||||
<ul class="new-component-template">
|
||||
% for name, category, has_markdown, boilerplate_name in sorted(templates):
|
||||
% if has_markdown or type != "problem":
|
||||
% if boilerplate_name is None:
|
||||
<li class="editor-md empty">
|
||||
<a href="#" data-category="${category}">
|
||||
<span class="name">${name}</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
% else:
|
||||
<li class="editor-md">
|
||||
<a href="#" data-category="${category}"
|
||||
data-boilerplate="${boilerplate_name}">
|
||||
<span class="name">${name}</span>
|
||||
</a>
|
||||
</li>
|
||||
% endif
|
||||
% endif
|
||||
|
||||
%endfor
|
||||
</ul>
|
||||
</div>
|
||||
% if type == "problem":
|
||||
<div class="tab" id="tab2">
|
||||
<ul class="new-component-template">
|
||||
% for name, category, has_markdown, boilerplate_name in sorted(templates):
|
||||
% if not has_markdown:
|
||||
<li class="editor-manual">
|
||||
<a href="#" data-category="${category}"
|
||||
data-boilerplate="${boilerplate_name}">
|
||||
<span class="name">${name}</span>
|
||||
</a>
|
||||
</li>
|
||||
% endif
|
||||
% endfor
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
% endif
|
||||
<a href="#" class="cancel-button">Cancel</a>
|
||||
</div>
|
||||
% endif
|
||||
% endfor
|
||||
</li>
|
||||
</ol>
|
||||
@@ -160,10 +171,15 @@
|
||||
<div class="window unit-location">
|
||||
<h4 class="header">${_("Unit Location")}</h4>
|
||||
<div class="window-contents">
|
||||
<div><input type="text" class="url" value="/courseware/${section.url_name}/${subsection.url_name}" disabled /></div>
|
||||
<div class="row wrapper-unit-id">
|
||||
<p class="unit-id">
|
||||
<span class="label">${_("Unit Identifier:")}</span>
|
||||
<input type="text" class="url value" value="${unit.location.name}" disabled />
|
||||
</p>
|
||||
</div>
|
||||
<ol>
|
||||
<li>
|
||||
<a href="#" class="section-item">${section.display_name_with_default}</a>
|
||||
<a href="${reverse('course_index', kwargs=dict(org=context_course.location.org, course=context_course.location.course, name=context_course.location.name))}" class="section-item">${section.display_name_with_default}</a>
|
||||
<ol>
|
||||
<li>
|
||||
<a href="${reverse('edit_subsection', args=[subsection.location])}" class="section-item">
|
||||
|
||||
@@ -34,7 +34,7 @@ This def will enumerate through a passed in subsection and list all of the units
|
||||
</li>
|
||||
% endfor
|
||||
<li>
|
||||
<a href="#" class="new-unit-item" data-template="${create_new_unit_template}" data-parent="${subsection.location}">
|
||||
<a href="#" class="new-unit-item" data-category="${new_unit_category}" data-parent="${subsection.location}">
|
||||
<span class="new-unit-icon"></span>New Unit
|
||||
</a>
|
||||
</li>
|
||||
|
||||
@@ -17,7 +17,7 @@ urlpatterns = ('', # nopep8
|
||||
url(r'^preview_component/(?P<location>.*?)$', 'contentstore.views.preview_component', name='preview_component'),
|
||||
url(r'^save_item$', 'contentstore.views.save_item', name='save_item'),
|
||||
url(r'^delete_item$', 'contentstore.views.delete_item', name='delete_item'),
|
||||
url(r'^clone_item$', 'contentstore.views.clone_item', name='clone_item'),
|
||||
url(r'^create_item$', 'contentstore.views.create_item', name='create_item'),
|
||||
url(r'^create_draft$', 'contentstore.views.create_draft', name='create_draft'),
|
||||
url(r'^publish_draft$', 'contentstore.views.publish_draft', name='publish_draft'),
|
||||
url(r'^unpublish_unit$', 'contentstore.views.unpublish_unit', name='unpublish_unit'),
|
||||
|
||||
@@ -4,9 +4,9 @@ WE'RE USING MIGRATIONS!
|
||||
If you make changes to this model, be sure to create an appropriate migration
|
||||
file and check it in at the same time as your model changes. To do that,
|
||||
|
||||
1. Go to the mitx dir
|
||||
2. django-admin.py schemamigration student --auto --settings=lms.envs.dev --pythonpath=. description_of_your_change
|
||||
3. Add the migration file created in mitx/common/djangoapps/external_auth/migrations/
|
||||
1. Go to the edx-platform dir
|
||||
2. ./manage.py lms schemamigration student --auto description_of_your_change
|
||||
3. Add the migration file created in edx-platform/common/djangoapps/external_auth/migrations/
|
||||
"""
|
||||
|
||||
from django.db import models
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pytz import UTC
|
||||
from django.http import HttpResponse
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from dogapi import dog_stats_api
|
||||
|
||||
|
||||
@dog_stats_api.timed('edxapp.heartbeat')
|
||||
def heartbeat(request):
|
||||
"""
|
||||
Simple view that a loadbalancer can check to verify that the app is up
|
||||
"""
|
||||
output = {
|
||||
'date': datetime.now().isoformat(),
|
||||
'date': datetime.now(UTC).isoformat(),
|
||||
'courses': [course.location.url() for course in modulestore().get_courses()],
|
||||
}
|
||||
return HttpResponse(json.dumps(output, indent=4))
|
||||
|
||||
@@ -43,6 +43,35 @@ def try_staticfiles_lookup(path):
|
||||
return url
|
||||
|
||||
|
||||
def replace_jump_to_id_urls(text, course_id, jump_to_id_base_url):
|
||||
"""
|
||||
This will replace a link to another piece of courseware to a 'jump_to'
|
||||
URL that will redirect to the right place in the courseware
|
||||
|
||||
NOTE: This is similar to replace_course_urls in terms of functionality
|
||||
but it is intended to be used when we only have a 'id' that the
|
||||
course author provides. This is much more helpful when using
|
||||
Studio authored courses since they don't need to know the path. This
|
||||
is also durable with respect to item moves.
|
||||
|
||||
text: The content over which to perform the subtitutions
|
||||
course_id: The course_id in which this rewrite happens
|
||||
jump_to_id_base_url:
|
||||
A app-tier (e.g. LMS) absolute path to the base of the handler that will perform the
|
||||
redirect. e.g. /courses/<org>/<course>/<run>/jump_to_id. NOTE the <id> will be appended to
|
||||
the end of this URL at re-write time
|
||||
|
||||
output: <text> after the link rewriting rules are applied
|
||||
"""
|
||||
|
||||
def replace_jump_to_id_url(match):
|
||||
quote = match.group('quote')
|
||||
rest = match.group('rest')
|
||||
return "".join([quote, jump_to_id_base_url + rest, quote])
|
||||
|
||||
return re.sub(_url_replace_regex('/jump_to_id/'), replace_jump_to_id_url, text)
|
||||
|
||||
|
||||
def replace_course_urls(text, course_id):
|
||||
"""
|
||||
Replace /course/$stuff urls with /courses/$course_id/$stuff urls
|
||||
@@ -53,7 +82,6 @@ def replace_course_urls(text, course_id):
|
||||
returns: text with the links replaced
|
||||
"""
|
||||
|
||||
|
||||
def replace_course_url(match):
|
||||
quote = match.group('quote')
|
||||
rest = match.group('rest')
|
||||
|
||||
@@ -20,7 +20,7 @@ class Command(BaseCommand):
|
||||
files and then uploads over SFTP to Pearson and stuffs the entry in an
|
||||
S3 bucket for archive purposes.
|
||||
|
||||
Usage: django-admin.py pearson-transfer --mode [import|export|both]
|
||||
Usage: ./manage.py pearson-transfer --mode [import|export|both]
|
||||
"""
|
||||
|
||||
option_list = BaseCommand.option_list + (
|
||||
|
||||
@@ -6,9 +6,9 @@ Migration Notes
|
||||
If you make changes to this model, be sure to create an appropriate migration
|
||||
file and check it in at the same time as your model changes. To do that,
|
||||
|
||||
1. Go to the mitx dir
|
||||
2. django-admin.py schemamigration student --auto --settings=lms.envs.dev --pythonpath=. description_of_your_change
|
||||
3. Add the migration file created in mitx/common/djangoapps/student/migrations/
|
||||
1. Go to the edx-platform dir
|
||||
2. ./manage.py lms schemamigration student --auto description_of_your_change
|
||||
3. Add the migration file created in edx-platform/common/djangoapps/student/migrations/
|
||||
"""
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
@@ -69,30 +69,33 @@ class UserProfile(models.Model):
|
||||
location = models.CharField(blank=True, max_length=255, db_index=True)
|
||||
|
||||
# Optional demographic data we started capturing from Fall 2012
|
||||
this_year = datetime.now().year
|
||||
this_year = datetime.now(UTC).year
|
||||
VALID_YEARS = range(this_year, this_year - 120, -1)
|
||||
year_of_birth = models.IntegerField(blank=True, null=True, db_index=True)
|
||||
GENDER_CHOICES = (('m', 'Male'), ('f', 'Female'), ('o', 'Other'))
|
||||
gender = models.CharField(blank=True, null=True, max_length=6, db_index=True,
|
||||
choices=GENDER_CHOICES)
|
||||
gender = models.CharField(
|
||||
blank=True, null=True, max_length=6, db_index=True, choices=GENDER_CHOICES
|
||||
)
|
||||
|
||||
# [03/21/2013] removed these, but leaving comment since there'll still be
|
||||
# p_se and p_oth in the existing data in db.
|
||||
# ('p_se', 'Doctorate in science or engineering'),
|
||||
# ('p_oth', 'Doctorate in another field'),
|
||||
LEVEL_OF_EDUCATION_CHOICES = (('p', 'Doctorate'),
|
||||
('m', "Master's or professional degree"),
|
||||
('b', "Bachelor's degree"),
|
||||
('a', "Associate's degree"),
|
||||
('hs', "Secondary/high school"),
|
||||
('jhs', "Junior secondary/junior high/middle school"),
|
||||
('el', "Elementary/primary school"),
|
||||
('none', "None"),
|
||||
('other', "Other"))
|
||||
LEVEL_OF_EDUCATION_CHOICES = (
|
||||
('p', 'Doctorate'),
|
||||
('m', "Master's or professional degree"),
|
||||
('b', "Bachelor's degree"),
|
||||
('a', "Associate's degree"),
|
||||
('hs', "Secondary/high school"),
|
||||
('jhs', "Junior secondary/junior high/middle school"),
|
||||
('el', "Elementary/primary school"),
|
||||
('none', "None"),
|
||||
('other', "Other")
|
||||
)
|
||||
level_of_education = models.CharField(
|
||||
blank=True, null=True, max_length=6, db_index=True,
|
||||
choices=LEVEL_OF_EDUCATION_CHOICES
|
||||
)
|
||||
blank=True, null=True, max_length=6, db_index=True,
|
||||
choices=LEVEL_OF_EDUCATION_CHOICES
|
||||
)
|
||||
mailing_address = models.TextField(blank=True, null=True)
|
||||
goals = models.TextField(blank=True, null=True)
|
||||
allow_certificate = models.BooleanField(default=1)
|
||||
@@ -307,18 +310,18 @@ class TestCenterUserForm(ModelForm):
|
||||
ACCOMMODATION_REJECTED_CODE = 'NONE'
|
||||
|
||||
ACCOMMODATION_CODES = (
|
||||
(ACCOMMODATION_REJECTED_CODE, 'No Accommodation Granted'),
|
||||
('EQPMNT', 'Equipment'),
|
||||
('ET12ET', 'Extra Time - 1/2 Exam Time'),
|
||||
('ET30MN', 'Extra Time - 30 Minutes'),
|
||||
('ETDBTM', 'Extra Time - Double Time'),
|
||||
('SEPRMM', 'Separate Room'),
|
||||
('SRREAD', 'Separate Room and Reader'),
|
||||
('SRRERC', 'Separate Room and Reader/Recorder'),
|
||||
('SRRECR', 'Separate Room and Recorder'),
|
||||
('SRSEAN', 'Separate Room and Service Animal'),
|
||||
('SRSGNR', 'Separate Room and Sign Language Interpreter'),
|
||||
)
|
||||
(ACCOMMODATION_REJECTED_CODE, 'No Accommodation Granted'),
|
||||
('EQPMNT', 'Equipment'),
|
||||
('ET12ET', 'Extra Time - 1/2 Exam Time'),
|
||||
('ET30MN', 'Extra Time - 30 Minutes'),
|
||||
('ETDBTM', 'Extra Time - Double Time'),
|
||||
('SEPRMM', 'Separate Room'),
|
||||
('SRREAD', 'Separate Room and Reader'),
|
||||
('SRRERC', 'Separate Room and Reader/Recorder'),
|
||||
('SRRECR', 'Separate Room and Recorder'),
|
||||
('SRSEAN', 'Separate Room and Service Animal'),
|
||||
('SRSGNR', 'Separate Room and Sign Language Interpreter'),
|
||||
)
|
||||
|
||||
ACCOMMODATION_CODE_DICT = {code: name for (code, name) in ACCOMMODATION_CODES}
|
||||
|
||||
@@ -572,7 +575,6 @@ class TestCenterRegistrationForm(ModelForm):
|
||||
return code
|
||||
|
||||
|
||||
|
||||
def get_testcenter_registration(user, course_id, exam_series_code):
|
||||
try:
|
||||
tcu = TestCenterUser.objects.get(user=user)
|
||||
|
||||
@@ -111,9 +111,9 @@ def get_date_for_press(publish_date):
|
||||
# strip off extra months, and just use the first:
|
||||
date = re.sub(multimonth_pattern, ", ", publish_date)
|
||||
if re.search(day_pattern, date):
|
||||
date = datetime.datetime.strptime(date, "%B %d, %Y")
|
||||
date = datetime.datetime.strptime(date, "%B %d, %Y").replace(tzinfo=UTC)
|
||||
else:
|
||||
date = datetime.datetime.strptime(date, "%B, %Y")
|
||||
date = datetime.datetime.strptime(date, "%B, %Y").replace(tzinfo=UTC)
|
||||
return date
|
||||
|
||||
|
||||
@@ -1100,7 +1100,7 @@ def confirm_email_change(request, key):
|
||||
meta = up.get_meta()
|
||||
if 'old_emails' not in meta:
|
||||
meta['old_emails'] = []
|
||||
meta['old_emails'].append([user.email, datetime.datetime.now().isoformat()])
|
||||
meta['old_emails'].append([user.email, datetime.datetime.now(UTC).isoformat()])
|
||||
up.set_meta(meta)
|
||||
up.save()
|
||||
# Send it to the old email...
|
||||
@@ -1198,7 +1198,7 @@ def accept_name_change_by_id(id):
|
||||
meta = up.get_meta()
|
||||
if 'old_names' not in meta:
|
||||
meta['old_names'] = []
|
||||
meta['old_names'].append([up.name, pnc.rationale, datetime.datetime.now().isoformat()])
|
||||
meta['old_names'].append([up.name, pnc.rationale, datetime.datetime.now(UTC).isoformat()])
|
||||
up.set_meta(meta)
|
||||
|
||||
up.name = pnc.new_name
|
||||
|
||||
@@ -12,7 +12,6 @@ from django.contrib.sessions.middleware import SessionMiddleware
|
||||
from student.models import CourseEnrollment
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.contentstore.django import contentstore
|
||||
from xmodule.templates import update_templates
|
||||
from urllib import quote_plus
|
||||
|
||||
|
||||
@@ -84,5 +83,4 @@ def clear_courses():
|
||||
# from the bash shell to drop it:
|
||||
# $ mongo test_xmodule --eval "db.dropDatabase()"
|
||||
modulestore().collection.drop()
|
||||
update_templates(modulestore('direct'))
|
||||
contentstore().fs_files.drop()
|
||||
|
||||
@@ -129,6 +129,13 @@ def should_have_link_with_id_and_text(step, link_id, text):
|
||||
assert_equals(link.text, text)
|
||||
|
||||
|
||||
@step(r'should see a link to "([^"]*)" with the text "([^"]*)"$')
|
||||
def should_have_link_with_path_and_text(step, path, text):
|
||||
link = world.browser.find_link_by_text(text)
|
||||
assert len(link) > 0
|
||||
assert_equals(link.first["href"], django_url(path))
|
||||
|
||||
|
||||
@step(r'should( not)? see "(.*)" (?:somewhere|anywhere) (?:in|on) (?:the|this) page')
|
||||
def should_see_in_the_page(step, doesnt_appear, text):
|
||||
if doesnt_appear:
|
||||
|
||||
@@ -23,15 +23,15 @@ class TestXmoduleModfiers(ModuleStoreTestCase):
|
||||
number='313', display_name='histogram test')
|
||||
section = ItemFactory.create(
|
||||
parent_location=course.location, display_name='chapter hist',
|
||||
template='i4x://edx/templates/chapter/Empty')
|
||||
category='chapter')
|
||||
problem = ItemFactory.create(
|
||||
parent_location=section.location, display_name='problem hist 1',
|
||||
template='i4x://edx/templates/problem/Blank_Common_Problem')
|
||||
category='problem')
|
||||
problem.has_score = False # don't trip trying to retrieve db data
|
||||
|
||||
late_problem = ItemFactory.create(
|
||||
parent_location=section.location, display_name='problem hist 2',
|
||||
template='i4x://edx/templates/problem/Blank_Common_Problem')
|
||||
category='problem')
|
||||
late_problem.lms.start = datetime.datetime.now(UTC) + datetime.timedelta(days=32)
|
||||
late_problem.has_score = False
|
||||
|
||||
|
||||
@@ -42,6 +42,28 @@ def wrap_xmodule(get_html, module, template, context=None):
|
||||
return _get_html
|
||||
|
||||
|
||||
def replace_jump_to_id_urls(get_html, course_id, jump_to_id_base_url):
|
||||
"""
|
||||
This will replace a link between courseware in the format
|
||||
/jump_to/<id> with a URL for a page that will correctly redirect
|
||||
This is similar to replace_course_urls, but much more flexible and
|
||||
durable for Studio authored courses. See more comments in static_replace.replace_jump_to_urls
|
||||
|
||||
course_id: The course_id in which this rewrite happens
|
||||
jump_to_id_base_url:
|
||||
A app-tier (e.g. LMS) absolute path to the base of the handler that will perform the
|
||||
redirect. e.g. /courses/<org>/<course>/<run>/jump_to_id. NOTE the <id> will be appended to
|
||||
the end of this URL at re-write time
|
||||
|
||||
output: a wrapped get_html() function pointer, which, when called, will apply the
|
||||
rewrite rules
|
||||
"""
|
||||
@wraps(get_html)
|
||||
def _get_html():
|
||||
return static_replace.replace_jump_to_id_urls(get_html(), course_id, jump_to_id_base_url)
|
||||
return _get_html
|
||||
|
||||
|
||||
def replace_course_urls(get_html, course_id):
|
||||
"""
|
||||
Updates the supplied module with a new get_html function that wraps
|
||||
@@ -89,6 +111,21 @@ def grade_histogram(module_id):
|
||||
return grades
|
||||
|
||||
|
||||
def save_module(get_html, module):
|
||||
"""
|
||||
Updates the given get_html function for the given module to save the fields
|
||||
after rendering.
|
||||
"""
|
||||
@wraps(get_html)
|
||||
def _get_html():
|
||||
"""Cache the rendered output, save, then return the output."""
|
||||
rendered_html = get_html()
|
||||
module.save()
|
||||
return rendered_html
|
||||
|
||||
return _get_html
|
||||
|
||||
|
||||
def add_histogram(get_html, module, user):
|
||||
"""
|
||||
Updates the supplied module with a new get_html function that wraps
|
||||
@@ -120,7 +157,7 @@ def add_histogram(get_html, module, user):
|
||||
# doesn't like symlinks)
|
||||
filepath = filename
|
||||
data_dir = osfs.root_path.rsplit('/')[-1]
|
||||
giturl = getattr(module.lms, 'giturl', '') or 'https://github.com/MITx'
|
||||
giturl = module.lms.giturl or 'https://github.com/MITx'
|
||||
edit_link = "%s/%s/tree/master/%s" % (giturl, data_dir, filepath)
|
||||
else:
|
||||
edit_link = False
|
||||
|
||||
@@ -32,6 +32,8 @@ import capa.xqueue_interface as xqueue_interface
|
||||
import capa.responsetypes as responsetypes
|
||||
from capa.safe_exec import safe_exec
|
||||
|
||||
from pytz import UTC
|
||||
|
||||
# dict of tagname, Response Class -- this should come from auto-registering
|
||||
response_tag_dict = dict([(x.response_tag, x) for x in responsetypes.__all__])
|
||||
|
||||
@@ -42,13 +44,22 @@ solution_tags = ['solution']
|
||||
response_properties = ["codeparam", "responseparam", "answer", "openendedparam"]
|
||||
|
||||
# special problem tags which should be turned into innocuous HTML
|
||||
html_transforms = {'problem': {'tag': 'div'},
|
||||
'text': {'tag': 'span'},
|
||||
'math': {'tag': 'span'},
|
||||
}
|
||||
html_transforms = {
|
||||
'problem': {'tag': 'div'},
|
||||
'text': {'tag': 'span'},
|
||||
'math': {'tag': 'span'},
|
||||
}
|
||||
|
||||
# These should be removed from HTML output, including all subelements
|
||||
html_problem_semantics = ["codeparam", "responseparam", "answer", "script", "hintgroup", "openendedparam", "openendedrubric"]
|
||||
html_problem_semantics = [
|
||||
"codeparam",
|
||||
"responseparam",
|
||||
"answer",
|
||||
"script",
|
||||
"hintgroup",
|
||||
"openendedparam",
|
||||
"openendedrubric"
|
||||
]
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -242,11 +253,15 @@ class LoncapaProblem(object):
|
||||
return None
|
||||
|
||||
# Get a list of timestamps of all queueing requests, then convert it to a DateTime object
|
||||
queuetime_strs = [self.correct_map.get_queuetime_str(answer_id)
|
||||
for answer_id in self.correct_map
|
||||
if self.correct_map.is_queued(answer_id)]
|
||||
queuetimes = [datetime.strptime(qt_str, xqueue_interface.dateformat)
|
||||
for qt_str in queuetime_strs]
|
||||
queuetime_strs = [
|
||||
self.correct_map.get_queuetime_str(answer_id)
|
||||
for answer_id in self.correct_map
|
||||
if self.correct_map.is_queued(answer_id)
|
||||
]
|
||||
queuetimes = [
|
||||
datetime.strptime(qt_str, xqueue_interface.dateformat).replace(tzinfo=UTC)
|
||||
for qt_str in queuetime_strs
|
||||
]
|
||||
|
||||
return max(queuetimes)
|
||||
|
||||
@@ -404,10 +419,16 @@ class LoncapaProblem(object):
|
||||
# open using ModuleSystem OSFS filestore
|
||||
ifp = self.system.filestore.open(filename)
|
||||
except Exception as err:
|
||||
log.warning('Error %s in problem xml include: %s' % (
|
||||
err, etree.tostring(inc, pretty_print=True)))
|
||||
log.warning('Cannot find file %s in %s' % (
|
||||
filename, self.system.filestore))
|
||||
log.warning(
|
||||
'Error %s in problem xml include: %s' % (
|
||||
err, etree.tostring(inc, pretty_print=True)
|
||||
)
|
||||
)
|
||||
log.warning(
|
||||
'Cannot find file %s in %s' % (
|
||||
filename, self.system.filestore
|
||||
)
|
||||
)
|
||||
# if debugging, don't fail - just log error
|
||||
# TODO (vshnayder): need real error handling, display to users
|
||||
if not self.system.get('DEBUG'):
|
||||
@@ -418,8 +439,11 @@ class LoncapaProblem(object):
|
||||
# read in and convert to XML
|
||||
incxml = etree.XML(ifp.read())
|
||||
except Exception as err:
|
||||
log.warning('Error %s in problem xml include: %s' % (
|
||||
err, etree.tostring(inc, pretty_print=True)))
|
||||
log.warning(
|
||||
'Error %s in problem xml include: %s' % (
|
||||
err, etree.tostring(inc, pretty_print=True)
|
||||
)
|
||||
)
|
||||
log.warning('Cannot parse XML in %s' % (filename))
|
||||
# if debugging, don't fail - just log error
|
||||
# TODO (vshnayder): same as above
|
||||
@@ -579,8 +603,9 @@ class LoncapaProblem(object):
|
||||
# let each Response render itself
|
||||
if problemtree in self.responders:
|
||||
overall_msg = self.correct_map.get_overall_message()
|
||||
return self.responders[problemtree].render_html(self._extract_html,
|
||||
response_msg=overall_msg)
|
||||
return self.responders[problemtree].render_html(
|
||||
self._extract_html, response_msg=overall_msg
|
||||
)
|
||||
|
||||
# let each custom renderer render itself:
|
||||
if problemtree.tag in customrender.registry.registered_tags():
|
||||
@@ -628,9 +653,10 @@ class LoncapaProblem(object):
|
||||
|
||||
answer_id = 1
|
||||
input_tags = inputtypes.registry.registered_tags()
|
||||
inputfields = tree.xpath("|".join(['//' + response.tag + '[@id=$id]//' + x
|
||||
for x in (input_tags + solution_tags)]),
|
||||
id=response_id_str)
|
||||
inputfields = tree.xpath(
|
||||
"|".join(['//' + response.tag + '[@id=$id]//' + x for x in (input_tags + solution_tags)]),
|
||||
id=response_id_str
|
||||
)
|
||||
|
||||
# assign one answer_id for each input type or solution type
|
||||
for entry in inputfields:
|
||||
|
||||
@@ -37,23 +37,27 @@ class CorrectMap(object):
|
||||
return self.cmap.__iter__()
|
||||
|
||||
# See the documentation for 'set_dict' for the use of kwargs
|
||||
def set(self,
|
||||
answer_id=None,
|
||||
correctness=None,
|
||||
npoints=None,
|
||||
msg='',
|
||||
hint='',
|
||||
hintmode=None,
|
||||
queuestate=None, **kwargs):
|
||||
def set(
|
||||
self,
|
||||
answer_id=None,
|
||||
correctness=None,
|
||||
npoints=None,
|
||||
msg='',
|
||||
hint='',
|
||||
hintmode=None,
|
||||
queuestate=None,
|
||||
**kwargs
|
||||
):
|
||||
|
||||
if answer_id is not None:
|
||||
self.cmap[str(answer_id)] = {'correctness': correctness,
|
||||
'npoints': npoints,
|
||||
'msg': msg,
|
||||
'hint': hint,
|
||||
'hintmode': hintmode,
|
||||
'queuestate': queuestate,
|
||||
}
|
||||
self.cmap[str(answer_id)] = {
|
||||
'correctness': correctness,
|
||||
'npoints': npoints,
|
||||
'msg': msg,
|
||||
'hint': hint,
|
||||
'hintmode': hintmode,
|
||||
'queuestate': queuestate,
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return repr(self.cmap)
|
||||
|
||||
@@ -460,10 +460,10 @@ class JSInput(InputTypeBase):
|
||||
DO NOT USE! HAS NOT BEEN TESTED BEYOND 700X PROBLEMS, AND MAY CHANGE IN
|
||||
BACKWARDS-INCOMPATIBLE WAYS.
|
||||
Inputtype for general javascript inputs. Intended to be used with
|
||||
customresponse.
|
||||
customresponse.
|
||||
Loads in a sandboxed iframe to help prevent css and js conflicts between
|
||||
frame and top-level window.
|
||||
|
||||
frame and top-level window.
|
||||
|
||||
iframe sandbox whitelist:
|
||||
- allow-scripts
|
||||
- allow-popups
|
||||
@@ -474,9 +474,9 @@ class JSInput(InputTypeBase):
|
||||
window elements.
|
||||
Example:
|
||||
|
||||
<jsinput html_file="/static/test.html"
|
||||
gradefn="grade"
|
||||
height="500"
|
||||
<jsinput html_file="/static/test.html"
|
||||
gradefn="grade"
|
||||
height="500"
|
||||
width="400"/>
|
||||
|
||||
See the documentation in the /doc/public folder for more information.
|
||||
@@ -500,7 +500,7 @@ class JSInput(InputTypeBase):
|
||||
Attribute('width', "400"), # iframe width
|
||||
Attribute('height', "300")] # iframe height
|
||||
|
||||
|
||||
|
||||
|
||||
def _extra_context(self):
|
||||
context = {
|
||||
@@ -510,11 +510,12 @@ class JSInput(InputTypeBase):
|
||||
|
||||
return context
|
||||
|
||||
|
||||
|
||||
|
||||
registry.register(JSInput)
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TextLine(InputTypeBase):
|
||||
"""
|
||||
A text line input. Can do math preview if "math"="1" is specified.
|
||||
@@ -1368,3 +1369,209 @@ class AnnotationInput(InputTypeBase):
|
||||
return extra_context
|
||||
|
||||
registry.register(AnnotationInput)
|
||||
|
||||
|
||||
class ChoiceTextGroup(InputTypeBase):
|
||||
"""
|
||||
Groups of radiobutton/checkboxes with text inputs.
|
||||
|
||||
Examples:
|
||||
RadioButton problem
|
||||
<problem>
|
||||
<startouttext/>
|
||||
A person rolls a standard die 100 times and records the results.
|
||||
On the first roll they received a "1". Given this information
|
||||
select the correct choice and fill in numbers to make it accurate.
|
||||
<endouttext/>
|
||||
<choicetextresponse>
|
||||
<radiotextgroup>
|
||||
<choice correct="false">The lowest number rolled was:
|
||||
<decoy_input/> and the highest number rolled was:
|
||||
<decoy_input/> .</choice>
|
||||
<choice correct="true">The lowest number rolled was <numtolerance_input answer="1"/>
|
||||
and there is not enough information to determine the highest number rolled.
|
||||
</choice>
|
||||
<choice correct="false">There is not enough information to determine the lowest
|
||||
number rolled, and the highest number rolled was:
|
||||
<decoy_input/> .
|
||||
</choice>
|
||||
</radiotextgroup>
|
||||
</choicetextresponse>
|
||||
</problem>
|
||||
|
||||
CheckboxProblem:
|
||||
<problem>
|
||||
<startouttext/>
|
||||
A person randomly selects 100 times, with replacement, from the list of numbers \(\sqrt{2}\) , 2, 3, 4 ,5 ,6
|
||||
and records the results. The first number they pick is \(\sqrt{2}\) Given this information
|
||||
select the correct choices and fill in numbers to make them accurate.
|
||||
<endouttext/>
|
||||
<choicetextresponse>
|
||||
<checkboxtextgroup>
|
||||
<choice correct="true">
|
||||
The lowest number selected was <numtolerance_input answer="1.4142" tolerance="0.01"/>
|
||||
</choice>
|
||||
<choice correct="false">
|
||||
The highest number selected was <decoy_input/> .
|
||||
</choice>
|
||||
<choice correct="true">There is not enough information given to determine the highest number
|
||||
which was selected.
|
||||
</choice>
|
||||
<choice correct="false">There is not enough information given to determine the lowest number
|
||||
selected.
|
||||
</choice>
|
||||
</checkboxtextgroup>
|
||||
</choicetextresponse>
|
||||
</problem>
|
||||
|
||||
In the preceding examples the <decoy_input/> is used to generate a textinput html element
|
||||
in the problem's display. Since it is inside of an incorrect choice, no answer given
|
||||
for it will be correct, and thus specifying an answer for it is not needed.
|
||||
"""
|
||||
template = "choicetext.html"
|
||||
tags = ['radiotextgroup', 'checkboxtextgroup']
|
||||
|
||||
def setup(self):
|
||||
"""
|
||||
Performs setup for the initial rendering of the problem.
|
||||
`self.html_input_type` determines whether this problem is displayed
|
||||
with radiobuttons or checkboxes
|
||||
|
||||
If the initial value of `self.value` is '' change it to {} so that
|
||||
the template has an empty dictionary to work with.
|
||||
|
||||
sets the value of self.choices to be equal to the return value of
|
||||
`self.extract_choices`
|
||||
"""
|
||||
self.text_input_values = {}
|
||||
if self.tag == 'radiotextgroup':
|
||||
self.html_input_type = "radio"
|
||||
elif self.tag == 'checkboxtextgroup':
|
||||
self.html_input_type = "checkbox"
|
||||
else:
|
||||
raise Exception("ChoiceGroup: unexpected tag {0}".format(self.tag))
|
||||
|
||||
if self.value == '':
|
||||
# Make `value` an empty dictionary, if it currently has an empty
|
||||
# value. This is necessary because the template expects a
|
||||
# dictionary.
|
||||
self.value = {}
|
||||
self.choices = self.extract_choices(self.xml)
|
||||
|
||||
@classmethod
|
||||
def get_attributes(cls):
|
||||
"""
|
||||
Returns a list of `Attribute` for this problem type
|
||||
"""
|
||||
return [
|
||||
Attribute("show_correctness", "always"),
|
||||
Attribute("submitted_message", "Answer received.")
|
||||
]
|
||||
|
||||
def _extra_context(self):
|
||||
"""
|
||||
Returns a dictionary of extra content necessary for rendering this InputType.
|
||||
|
||||
`input_type` is either 'radio' or 'checkbox' indicating whether the choices for
|
||||
this problem will have radiobuttons or checkboxes.
|
||||
"""
|
||||
return {
|
||||
'input_type': self.html_input_type,
|
||||
'choices': self.choices
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def extract_choices(element):
|
||||
"""
|
||||
Extracts choices from the xml for this problem type.
|
||||
If we have xml that is as follows(choice names will have been assigned
|
||||
by now)
|
||||
<radiotextgroup>
|
||||
<choice correct = "true" name ="1_2_1_choiceinput_0bc">
|
||||
The number
|
||||
<numtolerance_input name = "1_2_1_choiceinput0_numtolerance_input_0" answer="5"/>
|
||||
Is the mean of the list.
|
||||
</choice>
|
||||
<choice correct = "false" name = "1_2_1_choiceinput_1bc>
|
||||
False demonstration choice
|
||||
</choice>
|
||||
</radiotextgroup>
|
||||
|
||||
Choices are used for rendering the problem properly
|
||||
The function will setup choices as follows:
|
||||
choices =[
|
||||
("1_2_1_choiceinput_0bc",
|
||||
[{'type': 'text', 'contents': "The number", 'tail_text': '',
|
||||
'value': ''
|
||||
},
|
||||
{'type': 'textinput',
|
||||
'contents': "1_2_1_choiceinput0_numtolerance_input_0",
|
||||
'tail_text': 'Is the mean of the list',
|
||||
'value': ''
|
||||
}
|
||||
]
|
||||
),
|
||||
("1_2_1_choiceinput_1bc",
|
||||
[{'type': 'text', 'contents': "False demonstration choice",
|
||||
'tail_text': '',
|
||||
'value': ''
|
||||
}
|
||||
]
|
||||
)
|
||||
]
|
||||
"""
|
||||
|
||||
choices = []
|
||||
|
||||
for choice in element:
|
||||
if choice.tag != 'choice':
|
||||
raise Exception(
|
||||
"[capa.inputtypes.extract_choices] Expected a <choice>" +
|
||||
"tag; got {0} instead".format(choice.tag)
|
||||
)
|
||||
|
||||
components = []
|
||||
choice_text = ''
|
||||
if choice.text is not None:
|
||||
choice_text += choice.text
|
||||
# Initialize our dict for the next content
|
||||
adder = {
|
||||
'type': 'text',
|
||||
'contents': choice_text,
|
||||
'tail_text': '',
|
||||
'value': ''
|
||||
}
|
||||
components.append(adder)
|
||||
|
||||
for elt in choice:
|
||||
# for elements in the choice e.g. <text> <numtolerance_input>
|
||||
adder = {
|
||||
'type': 'text',
|
||||
'contents': '',
|
||||
'tail_text': '',
|
||||
'value': ''
|
||||
}
|
||||
tag_type = elt.tag
|
||||
# If the current `elt` is a <numtolerance_input> set the
|
||||
# `adder`type to 'numtolerance_input', and 'contents' to
|
||||
# the `elt`'s name.
|
||||
# Treat decoy_inputs and numtolerance_inputs the same in order
|
||||
# to prevent students from reading the Html and figuring out
|
||||
# which inputs are valid
|
||||
if tag_type in ('numtolerance_input', 'decoy_input'):
|
||||
# We set this to textinput, so that we get a textinput html
|
||||
# element.
|
||||
adder['type'] = 'textinput'
|
||||
adder['contents'] = elt.get('name')
|
||||
else:
|
||||
adder['contents'] = elt.text
|
||||
|
||||
# Add any tail text("is the mean" in the example)
|
||||
adder['tail_text'] = elt.tail if elt.tail else ''
|
||||
components.append(adder)
|
||||
|
||||
# Add the tuple for the current choice to the list of choices
|
||||
choices.append((choice.get("name"), components))
|
||||
return choices
|
||||
|
||||
registry.register(ChoiceTextGroup)
|
||||
|
||||
@@ -33,6 +33,7 @@ from shapely.geometry import Point, MultiPoint
|
||||
from calc import evaluator, UndefinedVariable
|
||||
from . import correctmap
|
||||
from datetime import datetime
|
||||
from pytz import UTC
|
||||
from .util import *
|
||||
from lxml import etree
|
||||
from lxml.html.soupparser import fromstring as fromstring_bs # uses Beautiful Soup!!! FIXME?
|
||||
@@ -1365,9 +1366,11 @@ class CodeResponse(LoncapaResponse):
|
||||
# Note that submission can be a file
|
||||
submission = student_answers[self.answer_id]
|
||||
except Exception as err:
|
||||
log.error('Error in CodeResponse %s: cannot get student answer for %s;'
|
||||
' student_answers=%s' %
|
||||
(err, self.answer_id, convert_files_to_filenames(student_answers)))
|
||||
log.error(
|
||||
'Error in CodeResponse %s: cannot get student answer for %s;'
|
||||
' student_answers=%s' %
|
||||
(err, self.answer_id, convert_files_to_filenames(student_answers))
|
||||
)
|
||||
raise Exception(err)
|
||||
|
||||
# We do not support xqueue within Studio.
|
||||
@@ -1381,19 +1384,20 @@ class CodeResponse(LoncapaResponse):
|
||||
#------------------------------------------------------------
|
||||
|
||||
qinterface = self.system.xqueue['interface']
|
||||
qtime = datetime.strftime(datetime.now(), xqueue_interface.dateformat)
|
||||
qtime = datetime.strftime(datetime.now(UTC), xqueue_interface.dateformat)
|
||||
|
||||
anonymous_student_id = self.system.anonymous_student_id
|
||||
|
||||
# Generate header
|
||||
queuekey = xqueue_interface.make_hashkey(str(self.system.seed) + qtime +
|
||||
anonymous_student_id +
|
||||
self.answer_id)
|
||||
queuekey = xqueue_interface.make_hashkey(
|
||||
str(self.system.seed) + qtime + anonymous_student_id + self.answer_id
|
||||
)
|
||||
callback_url = self.system.xqueue['construct_callback']()
|
||||
xheader = xqueue_interface.make_xheader(
|
||||
lms_callback_url=callback_url,
|
||||
lms_key=queuekey,
|
||||
queue_name=self.queue_name)
|
||||
queue_name=self.queue_name
|
||||
)
|
||||
|
||||
# Generate body
|
||||
if is_list_of_files(submission):
|
||||
@@ -1406,9 +1410,10 @@ class CodeResponse(LoncapaResponse):
|
||||
|
||||
# Metadata related to the student submission revealed to the external
|
||||
# grader
|
||||
student_info = {'anonymous_student_id': anonymous_student_id,
|
||||
'submission_time': qtime,
|
||||
}
|
||||
student_info = {
|
||||
'anonymous_student_id': anonymous_student_id,
|
||||
'submission_time': qtime,
|
||||
}
|
||||
contents.update({'student_info': json.dumps(student_info)})
|
||||
|
||||
# Submit request. When successful, 'msg' is the prior length of the
|
||||
@@ -2097,6 +2102,333 @@ class AnnotationResponse(LoncapaResponse):
|
||||
return option_ids[0]
|
||||
return None
|
||||
|
||||
|
||||
class ChoiceTextResponse(LoncapaResponse):
|
||||
"""
|
||||
Allows for multiple choice responses with text inputs
|
||||
Desired semantics match those of NumericalResponse and
|
||||
ChoiceResponse.
|
||||
"""
|
||||
|
||||
response_tag = 'choicetextresponse'
|
||||
max_inputfields = 1
|
||||
allowed_inputfields = ['choicetextgroup',
|
||||
'checkboxtextgroup',
|
||||
'radiotextgroup'
|
||||
]
|
||||
|
||||
def setup_response(self):
|
||||
"""
|
||||
Sets up three dictionaries for use later:
|
||||
`correct_choices`: These are the correct binary choices(radio/checkbox)
|
||||
`correct_inputs`: These are the numerical/string answers for required
|
||||
inputs.
|
||||
`answer_values`: This is a dict, keyed by the name of the binary choice
|
||||
which contains the correct answers for the text inputs separated by
|
||||
commas e.g. "1, 0.5"
|
||||
|
||||
`correct_choices` and `correct_inputs` are used for grading the problem
|
||||
and `answer_values` is used for displaying correct answers.
|
||||
|
||||
"""
|
||||
context = self.context
|
||||
self.correct_choices = {}
|
||||
self.assign_choice_names()
|
||||
self.correct_inputs = {}
|
||||
self.answer_values = {self.answer_id: []}
|
||||
correct_xml = self.xml.xpath('//*[@id=$id]//choice[@correct="true"]',
|
||||
id=self.xml.get('id'))
|
||||
for node in correct_xml:
|
||||
# For each correct choice, set the `parent_name` to the
|
||||
# current choice's name
|
||||
parent_name = node.get('name')
|
||||
# Add the name of the correct binary choice to the
|
||||
# correct choices list as a key. The value is not important.
|
||||
self.correct_choices[parent_name] = {'answer': ''}
|
||||
# Add the name of the parent to the list of correct answers
|
||||
self.answer_values[self.answer_id].append(parent_name)
|
||||
answer_list = []
|
||||
# Loop over <numtolerance_input> elements inside of the correct choices
|
||||
for child in node:
|
||||
answer = child.get('answer', None)
|
||||
if not answer:
|
||||
# If the question creator does not specify an answer for a
|
||||
# <numtolerance_input> inside of a correct choice, raise an error
|
||||
raise LoncapaProblemError(
|
||||
"Answer not provided for numtolerance_input"
|
||||
)
|
||||
# Contextualize the answer to allow script generated answers.
|
||||
answer = contextualize_text(answer, context)
|
||||
input_name = child.get('name')
|
||||
# Contextualize the tolerance to value.
|
||||
tolerance = contextualize_text(
|
||||
child.get('tolerance', '0'),
|
||||
context
|
||||
)
|
||||
# Add the answer and tolerance information for the current
|
||||
# numtolerance_input to `correct_inputs`
|
||||
self.correct_inputs[input_name] = {
|
||||
'answer': answer,
|
||||
'tolerance': tolerance
|
||||
}
|
||||
# Add the correct answer for this input to the list for show
|
||||
answer_list.append(answer)
|
||||
# Turn the list of numtolerance_input answers into a comma separated string.
|
||||
self.answer_values[parent_name] = ', '.join(answer_list)
|
||||
# Turn correct choices into a set. Allows faster grading.
|
||||
self.correct_choices = set(self.correct_choices.keys())
|
||||
|
||||
def assign_choice_names(self):
|
||||
"""
|
||||
Initialize name attributes in <choice> and <numtolerance_input> tags
|
||||
for this response.
|
||||
|
||||
Example:
|
||||
Assuming for simplicity that `self.answer_id` = '1_2_1'
|
||||
|
||||
Before the function is called `self.xml` =
|
||||
<radiotextgroup>
|
||||
<choice correct = "true">
|
||||
The number
|
||||
<numtolerance_input answer="5"/>
|
||||
Is the mean of the list.
|
||||
</choice>
|
||||
<choice correct = "false">
|
||||
False demonstration choice
|
||||
</choice>
|
||||
</radiotextgroup>
|
||||
|
||||
After this is called the choices and numtolerance_inputs will have a name
|
||||
attribute initialized and self.xml will be:
|
||||
|
||||
<radiotextgroup>
|
||||
<choice correct = "true" name ="1_2_1_choiceinput_0bc">
|
||||
The number
|
||||
<numtolerance_input name = "1_2_1_choiceinput0_numtolerance_input_0"
|
||||
answer="5"/>
|
||||
Is the mean of the list.
|
||||
</choice>
|
||||
<choice correct = "false" name = "1_2_1_choiceinput_1bc>
|
||||
False demonstration choice
|
||||
</choice>
|
||||
</radiotextgroup>
|
||||
"""
|
||||
|
||||
for index, choice in enumerate(
|
||||
self.xml.xpath('//*[@id=$id]//choice', id=self.xml.get('id'))
|
||||
):
|
||||
# Set the name attribute for <choices>
|
||||
# "bc" is appended at the end to indicate that this is a
|
||||
# binary choice as opposed to a numtolerance_input, this convention
|
||||
# is used when grading the problem
|
||||
choice.set(
|
||||
"name",
|
||||
self.answer_id + "_choiceinput_" + str(index) + "bc"
|
||||
)
|
||||
# Set Name attributes for <numtolerance_input> elements
|
||||
# Look for all <numtolerance_inputs> inside this choice.
|
||||
numtolerance_inputs = choice.findall('numtolerance_input')
|
||||
# Look for all <decoy_input> inside this choice
|
||||
decoys = choice.findall('decoy_input')
|
||||
# <decoy_input> would only be used in choices which do not contain
|
||||
# <numtolerance_input>
|
||||
inputs = numtolerance_inputs if numtolerance_inputs else decoys
|
||||
# Give each input inside of the choice a name combining
|
||||
# The ordinality of the choice, and the ordinality of the input
|
||||
# within that choice e.g. 1_2_1_choiceinput_0_numtolerance_input_1
|
||||
for ind, child in enumerate(inputs):
|
||||
child.set(
|
||||
"name",
|
||||
self.answer_id + "_choiceinput_" + str(index) +
|
||||
"_numtolerance_input_" + str(ind)
|
||||
)
|
||||
|
||||
def get_score(self, student_answers):
|
||||
"""
|
||||
Returns a `CorrectMap` showing whether `student_answers` are correct.
|
||||
|
||||
`student_answers` contains keys for binary inputs(radiobutton,
|
||||
checkbox) and numerical inputs. Keys ending with 'bc' are binary
|
||||
choice inputs otherwise they are text fields.
|
||||
|
||||
This method first separates the two
|
||||
types of answers and then grades them in separate methods.
|
||||
|
||||
The student is only correct if they have both the binary inputs and
|
||||
numerical inputs correct.
|
||||
"""
|
||||
answer_dict = student_answers.get(self.answer_id, "")
|
||||
binary_choices, numtolerance_inputs = self._split_answers_dict(answer_dict)
|
||||
# Check the binary choices first.
|
||||
choices_correct = self._check_student_choices(binary_choices)
|
||||
inputs_correct = self._check_student_inputs(numtolerance_inputs)
|
||||
# Only return correct if the student got both the binary
|
||||
# and numtolerance_inputs are correct
|
||||
correct = choices_correct and inputs_correct
|
||||
|
||||
return CorrectMap(
|
||||
self.answer_id,
|
||||
'correct' if correct else 'incorrect'
|
||||
)
|
||||
|
||||
def get_answers(self):
|
||||
"""
|
||||
Returns a dictionary containing the names of binary choices as keys
|
||||
and a string of answers to any numtolerance_inputs which they may have
|
||||
e.g {choice_1bc : "answer1, answer2", choice_2bc : ""}
|
||||
"""
|
||||
return self.answer_values
|
||||
|
||||
def _split_answers_dict(self, a_dict):
|
||||
"""
|
||||
Returns two dicts:
|
||||
`binary_choices` : dictionary {input_name: input_value} for
|
||||
the binary choices which the student selected.
|
||||
and
|
||||
`numtolerance_choices` : a dictionary {input_name: input_value}
|
||||
for the numtolerance_inputs inside of choices which were selected
|
||||
|
||||
Determines if an input is inside of a binary input by looking at
|
||||
the beginning of it's name.
|
||||
|
||||
For example. If a binary_choice was named '1_2_1_choiceinput_0bc'
|
||||
All of the numtolerance_inputs in it would have an idea that begins
|
||||
with '1_2_1_choice_input_0_numtolerance_input'
|
||||
|
||||
Splits the name of the numtolerance_input at the occurence of
|
||||
'_numtolerance_input_' and appends 'bc' to the end to get the name
|
||||
of the choice it is contained in.
|
||||
|
||||
Example:
|
||||
`a_dict` = {
|
||||
'1_2_1_choiceinput_0bc': '1_2_1_choiceinput_0bc',
|
||||
'1_2_1_choiceinput_0_numtolerance_input_0': '1',
|
||||
'1_2_1_choiceinput_0_numtolerance_input_1': '2'
|
||||
'1_2_1_choiceinput_1_numtolerance_input_0': '3'
|
||||
}
|
||||
|
||||
In this case, the binary choice is '1_2_1_choiceinput_0bc', and
|
||||
the numtolerance_inputs associated with it are
|
||||
'1_2_1_choiceinput_0_numtolerance_input_0', and
|
||||
'1_2_1_choiceinput_0_numtolerance_input_1'.
|
||||
|
||||
so the two return dictionaries would be
|
||||
`binary_choices` = {'1_2_1_choiceinput_0bc': '1_2_1_choiceinput_0bc'}
|
||||
and
|
||||
`numtolerance_choices` ={
|
||||
'1_2_1_choiceinput_0_numtolerance_input_0': '1',
|
||||
'1_2_1_choiceinput_0_numtolerance_input_1': '2'
|
||||
}
|
||||
|
||||
The entry '1_2_1_choiceinput_1_numtolerance_input_0': '3' is discarded
|
||||
because it was not inside of a selected binary choice, and no validation
|
||||
should be performed on numtolerance_inputs inside of non-selected choices.
|
||||
"""
|
||||
|
||||
# Initialize the two dictionaries that are returned
|
||||
numtolerance_choices = {}
|
||||
binary_choices = {}
|
||||
|
||||
# `selected_choices` is a list of binary choices which were "checked/selected"
|
||||
# when the student submitted the problem.
|
||||
# Keys in a_dict ending with 'bc' refer to binary choices.
|
||||
selected_choices = [key for key in a_dict if key.endswith("bc")]
|
||||
for key in selected_choices:
|
||||
binary_choices[key] = a_dict[key]
|
||||
|
||||
# Convert the name of a numtolerance_input into the name of the binary
|
||||
# choice that it is contained within, and append it to the list if
|
||||
# the numtolerance_input's parent binary_choice is contained in
|
||||
# `selected_choices`.
|
||||
selected_numtolerance_inputs = [
|
||||
key for key in a_dict if key.partition("_numtolerance_input_")[0] + "bc"
|
||||
in selected_choices
|
||||
]
|
||||
|
||||
for key in selected_numtolerance_inputs:
|
||||
numtolerance_choices[key] = a_dict[key]
|
||||
|
||||
return (binary_choices, numtolerance_choices)
|
||||
|
||||
def _check_student_choices(self, choices):
|
||||
"""
|
||||
Compares student submitted checkbox/radiobutton answers against
|
||||
the correct answers. Returns True or False.
|
||||
|
||||
True if all of the correct choices are selected and no incorrect
|
||||
choices are selected.
|
||||
"""
|
||||
student_choices = set(choices)
|
||||
required_selected = len(self.correct_choices - student_choices) == 0
|
||||
no_extra_selected = len(student_choices - self.correct_choices) == 0
|
||||
correct = required_selected and no_extra_selected
|
||||
return correct
|
||||
|
||||
def _check_student_inputs(self, numtolerance_inputs):
|
||||
"""
|
||||
Compares student submitted numerical answers against the correct
|
||||
answers and tolerances.
|
||||
|
||||
`numtolerance_inputs` is a dictionary {answer_name : answer_value}
|
||||
|
||||
Performs numerical validation by means of calling
|
||||
`compare_with_tolerance()` on all of `numtolerance_inputs`
|
||||
|
||||
Performs a call to `compare_with_tolerance` even on values for
|
||||
decoy_inputs. This is used to validate their numericality and
|
||||
raise an error if the student entered a non numerical expression.
|
||||
|
||||
Returns True if and only if all student inputs are correct.
|
||||
"""
|
||||
inputs_correct = True
|
||||
for answer_name, answer_value in numtolerance_inputs.iteritems():
|
||||
# If `self.corrrect_inputs` does not contain an entry for
|
||||
# `answer_name`, this means that answer_name is a decoy
|
||||
# input's value, and validation of its numericality is the
|
||||
# only thing of interest from the later call to
|
||||
# `compare_with_tolerance`.
|
||||
params = self.correct_inputs.get(answer_name, {'answer': 0})
|
||||
|
||||
correct_ans = params['answer']
|
||||
# Set the tolerance to '0' if it was not specified in the xml
|
||||
tolerance = params.get('tolerance', '0')
|
||||
# Make sure that the staff answer is a valid number
|
||||
try:
|
||||
correct_ans = complex(correct_ans)
|
||||
except ValueError:
|
||||
log.debug(
|
||||
"Content error--answer" +
|
||||
"'{0}' is not a valid complex number".format(correct_ans)
|
||||
)
|
||||
raise StudentInputError(
|
||||
"The Staff answer could not be interpreted as a number."
|
||||
)
|
||||
# Compare the student answer to the staff answer/ or to 0
|
||||
# if all that is important is verifying numericality
|
||||
try:
|
||||
partial_correct = compare_with_tolerance(
|
||||
evaluator(dict(), dict(), answer_value),
|
||||
correct_ans,
|
||||
tolerance
|
||||
)
|
||||
except:
|
||||
# Use the traceback-preserving version of re-raising with a
|
||||
# different type
|
||||
_, _, trace = sys.exc_info()
|
||||
|
||||
raise StudentInputError(
|
||||
"Could not interpret '{0}' as a number{1}".format(
|
||||
cgi.escape(answer_value),
|
||||
trace
|
||||
)
|
||||
)
|
||||
# Ignore the results of the comparisons which were just for
|
||||
# Numerical Validation.
|
||||
if answer_name in self.correct_inputs and not partial_correct:
|
||||
# If any input is not correct, set the return value to False
|
||||
inputs_correct = False
|
||||
return inputs_correct
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
# TEMPORARY: List of all response subclasses
|
||||
@@ -2116,4 +2448,5 @@ __all__ = [CodeResponse,
|
||||
MultipleChoiceResponse,
|
||||
TrueFalseResponse,
|
||||
JavascriptResponse,
|
||||
AnnotationResponse]
|
||||
AnnotationResponse,
|
||||
ChoiceTextResponse]
|
||||
|
||||
76
common/lib/capa/capa/templates/choicetext.html
Normal file
76
common/lib/capa/capa/templates/choicetext.html
Normal file
@@ -0,0 +1,76 @@
|
||||
<% element_checked = False %>
|
||||
% for choice_id, _ in choices:
|
||||
<%choice_id = choice_id %>
|
||||
%if choice_id in value:
|
||||
<% element_checked = True %>
|
||||
%endif
|
||||
%endfor
|
||||
<section id="choicetextinput_${id}" class="choicetextinput">
|
||||
<form class="choicetextgroup capa_inputtype" id="inputtype_${id}">
|
||||
<div class="script_placeholder" data-src="/static/js/capa/choicetextinput.js"/>
|
||||
<div class="indicator_container">
|
||||
% if input_type == 'checkbox' or not element_checked:
|
||||
% if status == 'unsubmitted':
|
||||
<span class="unanswered" style="display:inline-block;" id="status_${id}"></span>
|
||||
% elif status == 'correct':
|
||||
<span class="correct" id="status_${id}"></span>
|
||||
% elif status == 'incorrect':
|
||||
<span class="incorrect" id="status_${id}"></span>
|
||||
% elif status == 'incomplete':
|
||||
<span class="incorrect" id="status_${id}"></span>
|
||||
% endif
|
||||
% endif
|
||||
</div>
|
||||
|
||||
<fieldset>
|
||||
% for choice_id, choice_description in choices:
|
||||
<%choice_id= choice_id %>
|
||||
<section id="forinput${choice_id}"
|
||||
% if input_type == 'radio' and choice_id in value :
|
||||
<%
|
||||
if status == 'correct':
|
||||
correctness = 'correct'
|
||||
elif status == 'incorrect':
|
||||
correctness = 'incorrect'
|
||||
else:
|
||||
correctness = None
|
||||
%>
|
||||
% if correctness:
|
||||
class="choicetextgroup_${correctness}"
|
||||
% endif
|
||||
% endif
|
||||
>
|
||||
<input class="ctinput" type="${input_type}" name="choiceinput_${id}" id="${choice_id}" value="${choice_id}"
|
||||
|
||||
% if choice_id in value:
|
||||
checked="true"
|
||||
% endif
|
||||
/>
|
||||
|
||||
% for content_node in choice_description:
|
||||
% if content_node['type'] == 'text':
|
||||
<span class="mock_label">
|
||||
${content_node['contents']}
|
||||
</span>
|
||||
% else:
|
||||
<% my_id = content_node.get('contents','') %>
|
||||
<% my_val = value.get(my_id,'') %>
|
||||
<input class="ctinput" type="text" name="${content_node['contents']}" id="${content_node['contents']}" value="${my_val|h} "/>
|
||||
%endif
|
||||
<span class="mock_label">
|
||||
${content_node['tail_text']}
|
||||
</span>
|
||||
|
||||
% endfor
|
||||
<p id="answer_${choice_id}" class="answer"></p>
|
||||
</section>
|
||||
|
||||
% endfor
|
||||
<span id="answer_${id}"></span>
|
||||
</fieldset>
|
||||
<input class= "choicetextvalue" type="hidden" name="input_${id}{}" id="input_${id}" value="${value|h}" />
|
||||
% if show_correctness == "never" and (value or status not in ['unsubmitted']):
|
||||
<div class="capa_alert">${submitted_message}</div>
|
||||
%endif
|
||||
</form>
|
||||
</section>
|
||||
@@ -779,3 +779,109 @@ class SymbolicResponseXMLFactory(ResponseXMLFactory):
|
||||
|
||||
def create_input_element(self, **kwargs):
|
||||
return ResponseXMLFactory.textline_input_xml(**kwargs)
|
||||
|
||||
|
||||
class ChoiceTextResponseXMLFactory(ResponseXMLFactory):
|
||||
""" Factory for producing <choicetextresponse> xml """
|
||||
|
||||
def create_response_element(self, **kwargs):
|
||||
""" Create a <choicetextresponse> element """
|
||||
return etree.Element("choicetextresponse")
|
||||
|
||||
def create_input_element(self, **kwargs):
|
||||
""" Create a <checkboxgroup> element.
|
||||
choices can be specified in the following format:
|
||||
[("true", [{"answer": "5", "tolerance": 0}]),
|
||||
("false", [{"answer": "5", "tolerance": 0}])
|
||||
]
|
||||
|
||||
This indicates that the first checkbox/radio is correct and it
|
||||
contains a numtolerance_input with an answer of 5 and a tolerance of 0
|
||||
|
||||
It also indicates that the second has a second incorrect radiobutton
|
||||
or checkbox with a numtolerance_input.
|
||||
"""
|
||||
choices = kwargs.get('choices', [("true", {})])
|
||||
choice_inputs = []
|
||||
# Ensure that the first element of choices is an ordered
|
||||
# collection. It will start as a list, a tuple, or not a Container.
|
||||
if type(choices[0]) not in [list, tuple]:
|
||||
choices = [choices]
|
||||
|
||||
for choice in choices:
|
||||
correctness, answers = choice
|
||||
numtolerance_inputs = []
|
||||
# If the current `choice` contains any("answer": number)
|
||||
# elements, turn those into numtolerance_inputs
|
||||
if answers:
|
||||
# `answers` will be a list or tuple of answers or a single
|
||||
# answer, representing the answers for numtolerance_inputs
|
||||
# inside of this specific choice.
|
||||
|
||||
# Make sure that `answers` is an ordered collection for
|
||||
# convenience.
|
||||
if type(answers) not in [list, tuple]:
|
||||
answers = [answers]
|
||||
|
||||
numtolerance_inputs = [
|
||||
self._create_numtolerance_input_element(answer)
|
||||
for answer in answers
|
||||
]
|
||||
|
||||
choice_inputs.append(
|
||||
self._create_choice_element(
|
||||
correctness=correctness,
|
||||
inputs=numtolerance_inputs
|
||||
)
|
||||
)
|
||||
# Default type is 'radiotextgroup'
|
||||
input_type = kwargs.get('type', 'radiotextgroup')
|
||||
input_element = etree.Element(input_type)
|
||||
|
||||
for ind, choice in enumerate(choice_inputs):
|
||||
# Give each choice text equal to it's position(0,1,2...)
|
||||
choice.text = "choice_{0}".format(ind)
|
||||
input_element.append(choice)
|
||||
|
||||
return input_element
|
||||
|
||||
def _create_choice_element(self, **kwargs):
|
||||
"""
|
||||
Creates a choice element for a choictextproblem.
|
||||
Defaults to a correct choice with no numtolerance_input
|
||||
"""
|
||||
text = kwargs.get('text', '')
|
||||
correct = kwargs.get('correctness', "true")
|
||||
inputs = kwargs.get('inputs', [])
|
||||
choice_element = etree.Element("choice")
|
||||
choice_element.set("correct", correct)
|
||||
choice_element.text = text
|
||||
for inp in inputs:
|
||||
# Add all of the inputs as children of this choice
|
||||
choice_element.append(inp)
|
||||
|
||||
return choice_element
|
||||
|
||||
def _create_numtolerance_input_element(self, params):
|
||||
"""
|
||||
Creates a <numtolerance_input/> or <decoy_input/> element with
|
||||
optionally specified tolerance and answer.
|
||||
"""
|
||||
answer = params['answer'] if 'answer' in params else None
|
||||
# If there is not an answer specified, Then create a <decoy_input/>
|
||||
# otherwise create a <numtolerance_input/> and set its tolerance
|
||||
# and answer attributes.
|
||||
if answer:
|
||||
text_input = etree.Element("numtolerance_input")
|
||||
text_input.set('answer', answer)
|
||||
# If tolerance was specified, was specified use it, otherwise
|
||||
# Set the tolerance to "0"
|
||||
text_input.set(
|
||||
'tolerance',
|
||||
params['tolerance'] if 'tolerance' in params else "0"
|
||||
)
|
||||
|
||||
else:
|
||||
text_input = etree.Element("decoy_input")
|
||||
|
||||
return text_input
|
||||
|
||||
@@ -714,3 +714,170 @@ class DragAndDropTemplateTest(TemplateTestCase):
|
||||
# escaping the HTML. We should be able to traverse the XML tree.
|
||||
xpath = "//div[@class='drag_and_drop_problem_json']/p/b"
|
||||
self.assert_has_text(xml, xpath, 'HTML')
|
||||
|
||||
|
||||
class ChoiceTextGroupTemplateTest(TemplateTestCase):
|
||||
"""Test mako template for `<choicetextgroup>` input"""
|
||||
|
||||
TEMPLATE_NAME = 'choicetext.html'
|
||||
VALUE_DICT = {'1_choiceinput_0bc': '1_choiceinput_0bc', '1_choiceinput_0_textinput_0': '0',
|
||||
'1_choiceinput_1_textinput_0': '0'}
|
||||
EMPTY_DICT = {'1_choiceinput_0_textinput_0': '',
|
||||
'1_choiceinput_1_textinput_0': ''}
|
||||
BOTH_CHOICE_CHECKBOX = {'1_choiceinput_0bc': 'choiceinput_0',
|
||||
'1_choiceinput_1bc': 'choiceinput_1',
|
||||
'1_choiceinput_0_textinput_0': '0',
|
||||
'1_choiceinput_1_textinput_0': '0'}
|
||||
WRONG_CHOICE_CHECKBOX = {'1_choiceinput_1bc': 'choiceinput_1',
|
||||
'1_choiceinput_0_textinput_0': '0',
|
||||
'1_choiceinput_1_textinput_0': '0'}
|
||||
|
||||
def setUp(self):
|
||||
choices = [('1_choiceinput_0bc',
|
||||
[{'tail_text': '', 'type': 'text', 'value': '', 'contents': ''},
|
||||
{'tail_text': '', 'type': 'textinput', 'value': '', 'contents': 'choiceinput_0_textinput_0'}]),
|
||||
('1_choiceinput_1bc', [{'tail_text': '', 'type': 'text', 'value': '', 'contents': ''},
|
||||
{'tail_text': '', 'type': 'textinput', 'value': '', 'contents': 'choiceinput_1_textinput_0'}])]
|
||||
self.context = {'id': '1',
|
||||
'choices': choices,
|
||||
'status': 'correct',
|
||||
'input_type': 'radio',
|
||||
'value': self.VALUE_DICT}
|
||||
|
||||
super(ChoiceTextGroupTemplateTest, self).setUp()
|
||||
|
||||
def test_grouping_tag(self):
|
||||
"""
|
||||
Tests whether we are using a section or a label to wrap choice elements.
|
||||
Section is used for checkbox, so inputting text does not deselect
|
||||
"""
|
||||
input_tags = ('radio', 'checkbox')
|
||||
self.context['status'] = 'correct'
|
||||
xpath = "//section[@id='forinput1_choiceinput_0bc']"
|
||||
|
||||
self.context['value'] = {}
|
||||
for input_type in input_tags:
|
||||
self.context['input_type'] = input_type
|
||||
xml = self.render_to_xml(self.context)
|
||||
self.assert_has_xpath(xml, xpath, self.context)
|
||||
|
||||
def test_problem_marked_correct(self):
|
||||
"""Test conditions under which the entire problem
|
||||
(not a particular option) is marked correct"""
|
||||
|
||||
self.context['status'] = 'correct'
|
||||
self.context['input_type'] = 'checkbox'
|
||||
self.context['value'] = self.VALUE_DICT
|
||||
|
||||
# Should mark the entire problem correct
|
||||
xml = self.render_to_xml(self.context)
|
||||
xpath = "//div[@class='indicator_container']/span[@class='correct']"
|
||||
self.assert_has_xpath(xml, xpath, self.context)
|
||||
|
||||
# Should NOT mark individual options
|
||||
self.assert_no_xpath(xml, "//label[@class='choicetextgroup_incorrect']",
|
||||
self.context)
|
||||
|
||||
self.assert_no_xpath(xml, "//label[@class='choicetextgroup_correct']",
|
||||
self.context)
|
||||
|
||||
def test_problem_marked_incorrect(self):
|
||||
"""Test all conditions under which the entire problem
|
||||
(not a particular option) is marked incorrect"""
|
||||
grouping_tags = {'radio': 'label', 'checkbox': 'section'}
|
||||
conditions = [
|
||||
{'status': 'incorrect', 'input_type': 'radio', 'value': {}},
|
||||
{'status': 'incorrect', 'input_type': 'checkbox', 'value': self.WRONG_CHOICE_CHECKBOX},
|
||||
{'status': 'incorrect', 'input_type': 'checkbox', 'value': self.BOTH_CHOICE_CHECKBOX},
|
||||
{'status': 'incorrect', 'input_type': 'checkbox', 'value': self.VALUE_DICT},
|
||||
{'status': 'incomplete', 'input_type': 'radio', 'value': {}},
|
||||
{'status': 'incomplete', 'input_type': 'checkbox', 'value': self.WRONG_CHOICE_CHECKBOX},
|
||||
{'status': 'incomplete', 'input_type': 'checkbox', 'value': self.BOTH_CHOICE_CHECKBOX},
|
||||
{'status': 'incomplete', 'input_type': 'checkbox', 'value': self.VALUE_DICT}]
|
||||
|
||||
for test_conditions in conditions:
|
||||
self.context.update(test_conditions)
|
||||
xml = self.render_to_xml(self.context)
|
||||
xpath = "//div[@class='indicator_container']/span[@class='incorrect']"
|
||||
self.assert_has_xpath(xml, xpath, self.context)
|
||||
|
||||
# Should NOT mark individual options
|
||||
grouping_tag = grouping_tags[test_conditions['input_type']]
|
||||
self.assert_no_xpath(xml,
|
||||
"//{0}[@class='choicetextgroup_incorrect']".format(grouping_tag),
|
||||
self.context)
|
||||
|
||||
self.assert_no_xpath(xml,
|
||||
"//{0}[@class='choicetextgroup_correct']".format(grouping_tag),
|
||||
self.context)
|
||||
|
||||
def test_problem_marked_unsubmitted(self):
|
||||
"""Test all conditions under which the entire problem
|
||||
(not a particular option) is marked unanswered"""
|
||||
grouping_tags = {'radio': 'label', 'checkbox': 'section'}
|
||||
|
||||
conditions = [
|
||||
{'status': 'unsubmitted', 'input_type': 'radio', 'value': {}},
|
||||
{'status': 'unsubmitted', 'input_type': 'radio', 'value': self.EMPTY_DICT},
|
||||
{'status': 'unsubmitted', 'input_type': 'checkbox', 'value': {}},
|
||||
{'status': 'unsubmitted', 'input_type': 'checkbox', 'value': self.EMPTY_DICT},
|
||||
{'status': 'unsubmitted', 'input_type': 'checkbox', 'value': self.VALUE_DICT},
|
||||
{'status': 'unsubmitted', 'input_type': 'checkbox', 'value': self.BOTH_CHOICE_CHECKBOX}]
|
||||
|
||||
self.context['status'] = 'unanswered'
|
||||
|
||||
for test_conditions in conditions:
|
||||
self.context.update(test_conditions)
|
||||
xml = self.render_to_xml(self.context)
|
||||
xpath = "//div[@class='indicator_container']/span[@class='unanswered']"
|
||||
self.assert_has_xpath(xml, xpath, self.context)
|
||||
|
||||
# Should NOT mark individual options
|
||||
grouping_tag = grouping_tags[test_conditions['input_type']]
|
||||
self.assert_no_xpath(xml,
|
||||
"//{0}[@class='choicetextgroup_incorrect']".format(grouping_tag),
|
||||
self.context)
|
||||
|
||||
self.assert_no_xpath(xml,
|
||||
"//{0}[@class='choicetextgroup_correct']".format(grouping_tag),
|
||||
self.context)
|
||||
|
||||
def test_option_marked_correct(self):
|
||||
"""Test conditions under which a particular option
|
||||
(not the entire problem) is marked correct."""
|
||||
|
||||
conditions = [
|
||||
{'input_type': 'radio', 'value': self.VALUE_DICT}]
|
||||
|
||||
self.context['status'] = 'correct'
|
||||
|
||||
for test_conditions in conditions:
|
||||
self.context.update(test_conditions)
|
||||
xml = self.render_to_xml(self.context)
|
||||
xpath = "//section[@id='forinput1_choiceinput_0bc' and\
|
||||
@class='choicetextgroup_correct']"
|
||||
self.assert_has_xpath(xml, xpath, self.context)
|
||||
|
||||
# Should NOT mark the whole problem
|
||||
xpath = "//div[@class='indicator_container']/span"
|
||||
self.assert_no_xpath(xml, xpath, self.context)
|
||||
|
||||
def test_option_marked_incorrect(self):
|
||||
"""Test conditions under which a particular option
|
||||
(not the entire problem) is marked incorrect."""
|
||||
|
||||
conditions = [
|
||||
{'input_type': 'radio', 'value': self.VALUE_DICT}]
|
||||
|
||||
self.context['status'] = 'incorrect'
|
||||
|
||||
for test_conditions in conditions:
|
||||
self.context.update(test_conditions)
|
||||
xml = self.render_to_xml(self.context)
|
||||
xpath = "//section[@id='forinput1_choiceinput_0bc' and\
|
||||
@class='choicetextgroup_incorrect']"
|
||||
self.assert_has_xpath(xml, xpath, self.context)
|
||||
|
||||
# Should NOT mark the whole problem
|
||||
xpath = "//div[@class='indicator_container']/span"
|
||||
self.assert_no_xpath(xml, xpath, self.context)
|
||||
|
||||
@@ -860,3 +860,94 @@ class AnnotationInputTest(unittest.TestCase):
|
||||
|
||||
self.maxDiff = None
|
||||
self.assertDictEqual(context, expected)
|
||||
|
||||
|
||||
class TestChoiceText(unittest.TestCase):
|
||||
"""
|
||||
Tests for checkboxtextgroup inputs
|
||||
"""
|
||||
@staticmethod
|
||||
def build_choice_element(node_type, contents, tail_text, value):
|
||||
"""
|
||||
Builds a content node for a choice.
|
||||
"""
|
||||
# When xml is being parsed numtolerance_input and decoy_input tags map to textinput type
|
||||
# in order to provide the template with correct rendering information.
|
||||
if node_type in ('numtolerance_input', 'decoy_input'):
|
||||
node_type = 'textinput'
|
||||
choice = {'type': node_type, 'contents': contents, 'tail_text': tail_text, 'value': value}
|
||||
return choice
|
||||
|
||||
def check_group(self, tag, choice_tag, expected_input_type):
|
||||
"""
|
||||
Build a radio or checkbox group, parse it and check the resuls against the
|
||||
expected output.
|
||||
|
||||
`tag` should be 'checkboxtextgroup' or 'radiotextgroup'
|
||||
`choice_tag` is either 'choice' for proper xml, or any other value to trigger an error.
|
||||
`expected_input_type` is either 'radio' or 'checkbox'.
|
||||
"""
|
||||
xml_str = """
|
||||
<{tag}>
|
||||
<{choice_tag} correct="false" name="choiceinput_0">this is<numtolerance_input name="choiceinput_0_textinput_0"/>false</{choice_tag}>
|
||||
<choice correct="true" name="choiceinput_1">Is a number<decoy_input name="choiceinput_1_textinput_0"/><text>!</text></choice>
|
||||
</{tag}>
|
||||
""".format(tag=tag, choice_tag=choice_tag)
|
||||
element = etree.fromstring(xml_str)
|
||||
state = {
|
||||
'value': '{}',
|
||||
'id': 'choicetext_input',
|
||||
'status': 'answered'
|
||||
}
|
||||
|
||||
first_input = self.build_choice_element('numtolerance_input', 'choiceinput_0_textinput_0', 'false', '')
|
||||
second_input = self.build_choice_element('decoy_input', 'choiceinput_1_textinput_0', '', '')
|
||||
first_choice_content = self.build_choice_element('text', 'this is', '', '')
|
||||
second_choice_content = self.build_choice_element('text', 'Is a number', '', '')
|
||||
second_choice_text = self.build_choice_element('text', "!", '', '')
|
||||
|
||||
choices = [
|
||||
('choiceinput_0', [first_choice_content, first_input]),
|
||||
('choiceinput_1', [second_choice_content, second_input, second_choice_text])
|
||||
]
|
||||
|
||||
expected = {
|
||||
'msg': '',
|
||||
'input_type': expected_input_type,
|
||||
'choices': choices,
|
||||
'show_correctness': 'always',
|
||||
'submitted_message': 'Answer received.'
|
||||
}
|
||||
expected.update(state)
|
||||
the_input = lookup_tag(tag)(test_system(), element, state)
|
||||
context = the_input._get_render_context()
|
||||
self.assertEqual(context, expected)
|
||||
|
||||
def test_radiotextgroup(self):
|
||||
"""
|
||||
Test that a properly formatted radiotextgroup problem generates
|
||||
expected ouputs
|
||||
"""
|
||||
self.check_group('radiotextgroup', 'choice', 'radio')
|
||||
|
||||
def test_checkboxtextgroup(self):
|
||||
"""
|
||||
Test that a properly formatted checkboxtextgroup problem generates
|
||||
expected ouput
|
||||
"""
|
||||
self.check_group('checkboxtextgroup', 'choice', 'checkbox')
|
||||
|
||||
def test_invalid_tag(self):
|
||||
"""
|
||||
Test to ensure that an unrecognized inputtype tag causes an error
|
||||
"""
|
||||
with self.assertRaises(Exception):
|
||||
self.check_group('invalid', 'choice', 'checkbox')
|
||||
|
||||
def test_invalid_input_tag(self):
|
||||
"""
|
||||
Test to ensure having a tag other than <choice> inside of
|
||||
a checkbox or radiotextgroup problem raises an error.
|
||||
"""
|
||||
with self.assertRaisesRegexp(Exception, "Error in xml"):
|
||||
self.check_group('checkboxtextgroup', 'invalid', 'checkbox')
|
||||
|
||||
@@ -18,6 +18,8 @@ from capa.correctmap import CorrectMap
|
||||
from capa.util import convert_files_to_filenames
|
||||
from capa.xqueue_interface import dateformat
|
||||
|
||||
from pytz import UTC
|
||||
|
||||
|
||||
class ResponseTest(unittest.TestCase):
|
||||
""" Base class for tests of capa responses."""
|
||||
@@ -333,8 +335,9 @@ class SymbolicResponseTest(ResponseTest):
|
||||
|
||||
correct_map = problem.grade_answers(input_dict)
|
||||
|
||||
self.assertEqual(correct_map.get_correctness('1_2_1'),
|
||||
expected_correctness)
|
||||
self.assertEqual(
|
||||
correct_map.get_correctness('1_2_1'), expected_correctness
|
||||
)
|
||||
|
||||
|
||||
class OptionResponseTest(ResponseTest):
|
||||
@@ -702,7 +705,7 @@ class CodeResponseTest(ResponseTest):
|
||||
# Now we queue the LCP
|
||||
cmap = CorrectMap()
|
||||
for i, answer_id in enumerate(answer_ids):
|
||||
queuestate = CodeResponseTest.make_queuestate(i, datetime.now())
|
||||
queuestate = CodeResponseTest.make_queuestate(i, datetime.now(UTC))
|
||||
cmap.update(CorrectMap(answer_id=answer_ids[i], queuestate=queuestate))
|
||||
self.problem.correct_map.update(cmap)
|
||||
|
||||
@@ -718,7 +721,7 @@ class CodeResponseTest(ResponseTest):
|
||||
old_cmap = CorrectMap()
|
||||
for i, answer_id in enumerate(answer_ids):
|
||||
queuekey = 1000 + i
|
||||
queuestate = CodeResponseTest.make_queuestate(queuekey, datetime.now())
|
||||
queuestate = CodeResponseTest.make_queuestate(queuekey, datetime.now(UTC))
|
||||
old_cmap.update(CorrectMap(answer_id=answer_ids[i], queuestate=queuestate))
|
||||
|
||||
# Message format common to external graders
|
||||
@@ -778,13 +781,15 @@ class CodeResponseTest(ResponseTest):
|
||||
cmap = CorrectMap()
|
||||
for i, answer_id in enumerate(answer_ids):
|
||||
queuekey = 1000 + i
|
||||
latest_timestamp = datetime.now()
|
||||
latest_timestamp = datetime.now(UTC)
|
||||
queuestate = CodeResponseTest.make_queuestate(queuekey, latest_timestamp)
|
||||
cmap.update(CorrectMap(answer_id=answer_id, queuestate=queuestate))
|
||||
self.problem.correct_map.update(cmap)
|
||||
|
||||
# Queue state only tracks up to second
|
||||
latest_timestamp = datetime.strptime(datetime.strftime(latest_timestamp, dateformat), dateformat)
|
||||
latest_timestamp = datetime.strptime(
|
||||
datetime.strftime(latest_timestamp, dateformat), dateformat
|
||||
).replace(tzinfo=UTC)
|
||||
|
||||
self.assertEquals(self.problem.get_recentmost_queuetime(), latest_timestamp)
|
||||
|
||||
@@ -1429,3 +1434,357 @@ class AnnotationResponseTest(ResponseTest):
|
||||
msg="%s should be marked %s" % (answer_id, expected_correctness))
|
||||
self.assertEqual(expected_points, actual_points,
|
||||
msg="%s should have %d points" % (answer_id, expected_points))
|
||||
|
||||
|
||||
class ChoiceTextResponseTest(ResponseTest):
|
||||
"""
|
||||
Class containing setup and tests for ChoiceText responsetype.
|
||||
"""
|
||||
|
||||
from response_xml_factory import ChoiceTextResponseXMLFactory
|
||||
xml_factory_class = ChoiceTextResponseXMLFactory
|
||||
|
||||
# `TEST_INPUTS` is a dictionary mapping from
|
||||
# test_name to a representation of inputs for a test problem.
|
||||
TEST_INPUTS = {
|
||||
"1_choice_0_input_correct": [(True, [])],
|
||||
"1_choice_0_input_incorrect": [(False, [])],
|
||||
"1_choice_0_input_invalid_choice": [(False, []), (True, [])],
|
||||
"1_choice_1_input_correct": [(True, ["123"])],
|
||||
"1_input_script_correct": [(True, ["2"])],
|
||||
"1_input_script_incorrect": [(True, ["3.25"])],
|
||||
"1_choice_2_inputs_correct": [(True, ["123", "456"])],
|
||||
"1_choice_2_inputs_tolerance": [(True, ["123 + .5", "456 + 9"])],
|
||||
"1_choice_2_inputs_1_wrong": [(True, ["0", "456"])],
|
||||
"1_choice_2_inputs_both_wrong": [(True, ["0", "0"])],
|
||||
"1_choice_2_inputs_inputs_blank": [(True, ["", ""])],
|
||||
"1_choice_2_inputs_empty": [(False, [])],
|
||||
"1_choice_2_inputs_fail_tolerance": [(True, ["123 + 1.5", "456 + 9"])],
|
||||
"1_choice_1_input_within_tolerance": [(True, ["122.5"])],
|
||||
"1_choice_1_input_answer_incorrect": [(True, ["345"])],
|
||||
"1_choice_1_input_choice_incorrect": [(False, ["123"])],
|
||||
"2_choices_0_inputs_correct": [(False, []), (True, [])],
|
||||
"2_choices_0_inputs_incorrect": [(True, []), (False, [])],
|
||||
"2_choices_0_inputs_blank": [(False, []), (False, [])],
|
||||
"2_choices_1_input_1_correct": [(False, []), (True, ["123"])],
|
||||
"2_choices_1_input_1_incorrect": [(True, []), (False, ["123"])],
|
||||
"2_choices_1_input_input_wrong": [(False, []), (True, ["321"])],
|
||||
"2_choices_1_input_1_blank": [(False, []), (False, [])],
|
||||
"2_choices_1_input_2_correct": [(True, []), (False, ["123"])],
|
||||
"2_choices_1_input_2_incorrect": [(False, []), (True, ["123"])],
|
||||
"2_choices_2_inputs_correct": [(True, ["123"]), (False, [])],
|
||||
"2_choices_2_inputs_wrong_choice": [(False, ["123"]), (True, [])],
|
||||
"2_choices_2_inputs_wrong_input": [(True, ["321"]), (False, [])]
|
||||
}
|
||||
|
||||
# `TEST_SCENARIOS` is a dictionary of the form
|
||||
# {Test_Name" : (Test_Problem_name, correctness)}
|
||||
# correctness represents whether the problem should be graded as
|
||||
# correct or incorrect when the test is run.
|
||||
TEST_SCENARIOS = {
|
||||
"1_choice_0_input_correct": ("1_choice_0_input", "correct"),
|
||||
"1_choice_0_input_incorrect": ("1_choice_0_input", "incorrect"),
|
||||
"1_choice_0_input_invalid_choice": ("1_choice_0_input", "incorrect"),
|
||||
"1_input_script_correct": ("1_input_script", "correct"),
|
||||
"1_input_script_incorrect": ("1_input_script", "incorrect"),
|
||||
"1_choice_2_inputs_correct": ("1_choice_2_inputs", "correct"),
|
||||
"1_choice_2_inputs_tolerance": ("1_choice_2_inputs", "correct"),
|
||||
"1_choice_2_inputs_1_wrong": ("1_choice_2_inputs", "incorrect"),
|
||||
"1_choice_2_inputs_both_wrong": ("1_choice_2_inputs", "incorrect"),
|
||||
"1_choice_2_inputs_inputs_blank": ("1_choice_2_inputs", "incorrect"),
|
||||
"1_choice_2_inputs_empty": ("1_choice_2_inputs", "incorrect"),
|
||||
"1_choice_2_inputs_fail_tolerance": ("1_choice_2_inputs", "incorrect"),
|
||||
"1_choice_1_input_correct": ("1_choice_1_input", "correct"),
|
||||
"1_choice_1_input_within_tolerance": ("1_choice_1_input", "correct"),
|
||||
"1_choice_1_input_answer_incorrect": ("1_choice_1_input", "incorrect"),
|
||||
"1_choice_1_input_choice_incorrect": ("1_choice_1_input", "incorrect"),
|
||||
"2_choices_0_inputs_correct": ("2_choices_0_inputs", "correct"),
|
||||
"2_choices_0_inputs_incorrect": ("2_choices_0_inputs", "incorrect"),
|
||||
"2_choices_0_inputs_blank": ("2_choices_0_inputs", "incorrect"),
|
||||
"2_choices_1_input_1_correct": ("2_choices_1_input_1", "correct"),
|
||||
"2_choices_1_input_1_incorrect": ("2_choices_1_input_1", "incorrect"),
|
||||
"2_choices_1_input_input_wrong": ("2_choices_1_input_1", "incorrect"),
|
||||
"2_choices_1_input_1_blank": ("2_choices_1_input_1", "incorrect"),
|
||||
"2_choices_1_input_2_correct": ("2_choices_1_input_2", "correct"),
|
||||
"2_choices_1_input_2_incorrect": ("2_choices_1_input_2", "incorrect"),
|
||||
"2_choices_2_inputs_correct": ("2_choices_2_inputs", "correct"),
|
||||
"2_choices_2_inputs_wrong_choice": ("2_choices_2_inputs", "incorrect"),
|
||||
"2_choices_2_inputs_wrong_input": ("2_choices_2_inputs", "incorrect")
|
||||
}
|
||||
|
||||
# Dictionary that maps from problem_name to arguments for
|
||||
# _make_problem, that will create the problem.
|
||||
TEST_PROBLEM_ARGS = {
|
||||
"1_choice_0_input": {"choices": ("true", {}), "script": ''},
|
||||
"1_choice_1_input": {
|
||||
"choices": ("true", {"answer": "123", "tolerance": "1"}),
|
||||
"script": ''
|
||||
},
|
||||
|
||||
"1_input_script": {
|
||||
"choices": ("true", {"answer": "$computed_response", "tolerance": "1"}),
|
||||
"script": "computed_response = math.sqrt(4)"
|
||||
},
|
||||
|
||||
"1_choice_2_inputs": {
|
||||
"choices": [
|
||||
(
|
||||
"true", (
|
||||
{"answer": "123", "tolerance": "1"},
|
||||
{"answer": "456", "tolerance": "10"}
|
||||
)
|
||||
)
|
||||
],
|
||||
"script": ''
|
||||
},
|
||||
"2_choices_0_inputs": {
|
||||
"choices": [("false", {}), ("true", {})],
|
||||
"script": ''
|
||||
|
||||
},
|
||||
"2_choices_1_input_1": {
|
||||
"choices": [
|
||||
("false", {}), ("true", {"answer": "123", "tolerance": "0"})
|
||||
],
|
||||
"script": ''
|
||||
},
|
||||
"2_choices_1_input_2": {
|
||||
"choices": [("true", {}), ("false", {"answer": "123", "tolerance": "0"})],
|
||||
"script": ''
|
||||
},
|
||||
"2_choices_2_inputs": {
|
||||
"choices": [
|
||||
("true", {"answer": "123", "tolerance": "0"}),
|
||||
("false", {"answer": "999", "tolerance": "0"})
|
||||
],
|
||||
"script": ''
|
||||
}
|
||||
}
|
||||
|
||||
def _make_problem(self, choices, in_type='radiotextgroup', script=''):
|
||||
"""
|
||||
Convenience method to fill in default values for script and
|
||||
type if needed, then call self.build_problem
|
||||
"""
|
||||
return self.build_problem(
|
||||
choices=choices,
|
||||
type=in_type,
|
||||
script=script
|
||||
)
|
||||
|
||||
def _make_answer_dict(self, choice_list):
|
||||
"""
|
||||
Convenience method to make generation of answers less tedious,
|
||||
pass in an iterable argument with elements of the form: [bool, [ans,]]
|
||||
Will generate an answer dict for those options
|
||||
"""
|
||||
|
||||
answer_dict = {}
|
||||
for index, choice_answers_pair in enumerate(choice_list):
|
||||
# Choice is whether this choice is correct
|
||||
# Answers contains a list of answers to textinpts for the choice
|
||||
choice, answers = choice_answers_pair
|
||||
|
||||
if choice:
|
||||
# Radio/Checkbox inputs in choicetext problems follow
|
||||
# a naming convention that gives them names ending with "bc"
|
||||
choice_id = "1_2_1_choiceinput_{index}bc".format(index=index)
|
||||
choice_value = "choiceinput_{index}".format(index=index)
|
||||
answer_dict[choice_id] = choice_value
|
||||
# Build the names for the numtolerance_inputs and add their answers
|
||||
# to `answer_dict`.
|
||||
for ind, answer in enumerate(answers):
|
||||
# In `answer_id` `index` represents the ordinality of the
|
||||
# choice and `ind` represents the ordinality of the
|
||||
# numtolerance_input inside the parent choice.
|
||||
answer_id = "1_2_1_choiceinput_{index}_numtolerance_input_{ind}".format(
|
||||
index=index,
|
||||
ind=ind
|
||||
)
|
||||
answer_dict[answer_id] = answer
|
||||
|
||||
return answer_dict
|
||||
|
||||
def test_invalid_xml(self):
|
||||
"""
|
||||
Test that build problem raises errors for invalid options
|
||||
"""
|
||||
with self.assertRaises(Exception):
|
||||
self.build_problem(type="invalidtextgroup")
|
||||
|
||||
def test_valid_xml(self):
|
||||
"""
|
||||
Test that `build_problem` builds valid xml
|
||||
"""
|
||||
self.build_problem()
|
||||
self.assertTrue(True)
|
||||
|
||||
def test_unchecked_input_not_validated(self):
|
||||
"""
|
||||
Test that a student can have a non numeric answer in an unselected
|
||||
choice without causing an error to be raised when the problem is
|
||||
checked.
|
||||
"""
|
||||
|
||||
two_choice_two_input = self._make_problem(
|
||||
[
|
||||
("true", {"answer": "123", "tolerance": "1"}),
|
||||
("false", {})
|
||||
],
|
||||
"checkboxtextgroup"
|
||||
)
|
||||
|
||||
self.assert_grade(
|
||||
two_choice_two_input,
|
||||
self._make_answer_dict([(True, ["1"]), (False, ["Platypus"])]),
|
||||
"incorrect"
|
||||
)
|
||||
|
||||
def test_interpret_error(self):
|
||||
"""
|
||||
Test that student answers that cannot be interpeted as numbers
|
||||
cause the response type to raise an error.
|
||||
"""
|
||||
two_choice_two_input = self._make_problem(
|
||||
[
|
||||
("true", {"answer": "123", "tolerance": "1"}),
|
||||
("false", {})
|
||||
],
|
||||
"checkboxtextgroup"
|
||||
)
|
||||
|
||||
with self.assertRaisesRegexp(StudentInputError, "Could not interpret"):
|
||||
# Test that error is raised for input in selected correct choice.
|
||||
self.assert_grade(
|
||||
two_choice_two_input,
|
||||
self._make_answer_dict([(True, ["Platypus"])]),
|
||||
"correct"
|
||||
)
|
||||
|
||||
with self.assertRaisesRegexp(StudentInputError, "Could not interpret"):
|
||||
# Test that error is raised for input in selected incorrect choice.
|
||||
self.assert_grade(
|
||||
two_choice_two_input,
|
||||
self._make_answer_dict([(True, ["1"]), (True, ["Platypus"])]),
|
||||
"correct"
|
||||
)
|
||||
|
||||
def test_staff_answer_error(self):
|
||||
broken_problem = self._make_problem(
|
||||
[("true", {"answer": "Platypus", "tolerance": "0"}),
|
||||
("true", {"answer": "edX", "tolerance": "0"})
|
||||
],
|
||||
"checkboxtextgroup"
|
||||
)
|
||||
with self.assertRaisesRegexp(
|
||||
StudentInputError,
|
||||
"The Staff answer could not be interpreted as a number."
|
||||
):
|
||||
self.assert_grade(
|
||||
broken_problem,
|
||||
self._make_answer_dict(
|
||||
[(True, ["1"]), (True, ["1"])]
|
||||
),
|
||||
"correct"
|
||||
)
|
||||
|
||||
def test_radio_grades(self):
|
||||
"""
|
||||
Test that confirms correct operation of grading when the inputtag is
|
||||
radiotextgroup.
|
||||
"""
|
||||
|
||||
for name, inputs in self.TEST_INPUTS.iteritems():
|
||||
# Turn submission into the form expected when grading this problem.
|
||||
submission = self._make_answer_dict(inputs)
|
||||
# Lookup the problem_name, and the whether this test problem
|
||||
# and inputs should be graded as correct or incorrect.
|
||||
problem_name, correctness = self.TEST_SCENARIOS[name]
|
||||
# Load the args needed to build the problem for this test.
|
||||
problem_args = self.TEST_PROBLEM_ARGS[problem_name]
|
||||
test_choices = problem_args["choices"]
|
||||
test_script = problem_args["script"]
|
||||
# Build the actual problem for the test.
|
||||
test_problem = self._make_problem(test_choices, 'radiotextgroup', test_script)
|
||||
# Make sure the actual grade matches the expected grade.
|
||||
self.assert_grade(
|
||||
test_problem,
|
||||
submission,
|
||||
correctness,
|
||||
msg="{0} should be {1}".format(
|
||||
name,
|
||||
correctness
|
||||
)
|
||||
)
|
||||
|
||||
def test_checkbox_grades(self):
|
||||
"""
|
||||
Test that confirms correct operation of grading when the inputtag is
|
||||
checkboxtextgroup.
|
||||
"""
|
||||
# Dictionary from name of test_scenario to (problem_name, correctness)
|
||||
# Correctness is used to test whether the problem was graded properly
|
||||
scenarios = {
|
||||
"2_choices_correct": ("checkbox_two_choices", "correct"),
|
||||
"2_choices_incorrect": ("checkbox_two_choices", "incorrect"),
|
||||
|
||||
"2_choices_2_inputs_correct": (
|
||||
"checkbox_2_choices_2_inputs",
|
||||
"correct"
|
||||
),
|
||||
|
||||
"2_choices_2_inputs_missing_choice": (
|
||||
"checkbox_2_choices_2_inputs",
|
||||
"incorrect"
|
||||
),
|
||||
|
||||
"2_choices_2_inputs_wrong_input": (
|
||||
"checkbox_2_choices_2_inputs",
|
||||
"incorrect"
|
||||
)
|
||||
}
|
||||
# Dictionary scenario_name: test_inputs
|
||||
inputs = {
|
||||
"2_choices_correct": [(True, []), (True, [])],
|
||||
"2_choices_incorrect": [(True, []), (False, [])],
|
||||
"2_choices_2_inputs_correct": [(True, ["123"]), (True, ["456"])],
|
||||
"2_choices_2_inputs_missing_choice": [
|
||||
(True, ["123"]), (False, ["456"])
|
||||
],
|
||||
"2_choices_2_inputs_wrong_input": [
|
||||
(True, ["123"]), (True, ["654"])
|
||||
]
|
||||
}
|
||||
|
||||
# Two choice zero input problem with both choices being correct.
|
||||
checkbox_two_choices = self._make_problem(
|
||||
[("true", {}), ("true", {})], "checkboxtextgroup"
|
||||
)
|
||||
# Two choice two input problem with both choices correct.
|
||||
checkbox_two_choices_two_inputs = self._make_problem(
|
||||
[("true", {"answer": "123", "tolerance": "0"}),
|
||||
("true", {"answer": "456", "tolerance": "0"})
|
||||
],
|
||||
"checkboxtextgroup"
|
||||
)
|
||||
|
||||
# Dictionary problem_name: problem
|
||||
problems = {
|
||||
"checkbox_two_choices": checkbox_two_choices,
|
||||
"checkbox_2_choices_2_inputs": checkbox_two_choices_two_inputs
|
||||
}
|
||||
|
||||
for name, inputs in inputs.iteritems():
|
||||
submission = self._make_answer_dict(inputs)
|
||||
# Load the test problem's name and desired correctness
|
||||
problem_name, correctness = scenarios[name]
|
||||
# Load the problem
|
||||
problem = problems[problem_name]
|
||||
|
||||
# Make sure the actual grade matches the expected grade
|
||||
self.assert_grade(
|
||||
problem,
|
||||
submission,
|
||||
correctness,
|
||||
msg="{0} should be {1}".format(name, correctness)
|
||||
)
|
||||
|
||||
@@ -30,9 +30,11 @@ def make_xheader(lms_callback_url, lms_key, queue_name):
|
||||
'queue_name': designate a specific queue within xqueue server, e.g. 'MITx-6.00x' (string)
|
||||
}
|
||||
"""
|
||||
return json.dumps({'lms_callback_url': lms_callback_url,
|
||||
'lms_key': lms_key,
|
||||
'queue_name': queue_name})
|
||||
return json.dumps({
|
||||
'lms_callback_url': lms_callback_url,
|
||||
'lms_key': lms_key,
|
||||
'queue_name': queue_name
|
||||
})
|
||||
|
||||
|
||||
def parse_xreply(xreply):
|
||||
@@ -60,7 +62,7 @@ class XQueueInterface(object):
|
||||
'''
|
||||
|
||||
def __init__(self, url, django_auth, requests_auth=None):
|
||||
self.url = url
|
||||
self.url = url
|
||||
self.auth = django_auth
|
||||
self.session = requests.session(auth=requests_auth)
|
||||
|
||||
@@ -95,13 +97,13 @@ class XQueueInterface(object):
|
||||
|
||||
return (error, msg)
|
||||
|
||||
|
||||
def _login(self):
|
||||
payload = {'username': self.auth['username'],
|
||||
'password': self.auth['password']}
|
||||
payload = {
|
||||
'username': self.auth['username'],
|
||||
'password': self.auth['password']
|
||||
}
|
||||
return self._http_post(self.url + '/xqueue/login/', payload)
|
||||
|
||||
|
||||
def _send_to_queue(self, header, body, files_to_upload):
|
||||
payload = {'xqueue_header': header,
|
||||
'xqueue_body': body}
|
||||
@@ -112,7 +114,6 @@ class XQueueInterface(object):
|
||||
|
||||
return self._http_post(self.url + '/xqueue/submit/', payload, files=files)
|
||||
|
||||
|
||||
def _http_post(self, url, data, files=None):
|
||||
try:
|
||||
r = self.session.post(url, data=data, files=files)
|
||||
|
||||
@@ -80,8 +80,6 @@ class ABTestModule(ABTestFields, XModule):
|
||||
class ABTestDescriptor(ABTestFields, RawDescriptor, XmlDescriptor):
|
||||
module_class = ABTestModule
|
||||
|
||||
template_dir_name = "abtest"
|
||||
|
||||
@classmethod
|
||||
def definition_from_xml(cls, xml_object, system):
|
||||
"""
|
||||
|
||||
@@ -6,12 +6,37 @@ from pkg_resources import resource_string
|
||||
from xmodule.x_module import XModule
|
||||
from xmodule.raw_module import RawDescriptor
|
||||
from xblock.core import Scope, String
|
||||
import textwrap
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AnnotatableFields(object):
|
||||
data = String(help="XML data for the annotation", scope=Scope.content)
|
||||
data = String(help="XML data for the annotation", scope=Scope.content,
|
||||
default=textwrap.dedent(
|
||||
"""\
|
||||
<annotatable>
|
||||
<instructions>
|
||||
<p>Enter your (optional) instructions for the exercise in HTML format.</p>
|
||||
<p>Annotations are specified by an <code><annotation></code> tag which may may have the following attributes:</p>
|
||||
<ul class="instructions-template">
|
||||
<li><code>title</code> (optional). Title of the annotation. Defaults to <i>Commentary</i> if omitted.</li>
|
||||
<li><code>body</code> (<b>required</b>). Text of the annotation.</li>
|
||||
<li><code>problem</code> (optional). Numeric index of the problem associated with this annotation. This is a zero-based index, so the first problem on the page would have <code>problem="0"</code>.</li>
|
||||
<li><code>highlight</code> (optional). Possible values: yellow, red, orange, green, blue, or purple. Defaults to yellow if this attribute is omitted.</li>
|
||||
</ul>
|
||||
</instructions>
|
||||
<p>Add your HTML with annotation spans here.</p>
|
||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. <annotation title="My title" body="My comment" highlight="yellow" problem="0">Ut sodales laoreet est, egestas gravida felis egestas nec.</annotation> Aenean at volutpat erat. Cras commodo viverra nibh in aliquam.</p>
|
||||
<p>Nulla facilisi. <annotation body="Basic annotation example." problem="1">Pellentesque id vestibulum libero.</annotation> Suspendisse potenti. Morbi scelerisque nisi vitae felis dictum mattis. Nam sit amet magna elit. Nullam volutpat cursus est, sit amet sagittis odio vulputate et. Curabitur euismod, orci in vulputate imperdiet, augue lorem tempor purus, id aliquet augue turpis a est. Aenean a sagittis libero. Praesent fringilla pretium magna, non condimentum risus elementum nec. Pellentesque faucibus elementum pharetra. Pellentesque vitae metus eros.</p>
|
||||
</annotatable>
|
||||
"""))
|
||||
display_name = String(
|
||||
display_name="Display Name",
|
||||
help="Display name for this module",
|
||||
scope=Scope.settings,
|
||||
default='Annotation',
|
||||
)
|
||||
|
||||
|
||||
class AnnotatableModule(AnnotatableFields, XModule):
|
||||
@@ -125,5 +150,4 @@ class AnnotatableModule(AnnotatableFields, XModule):
|
||||
|
||||
class AnnotatableDescriptor(AnnotatableFields, RawDescriptor):
|
||||
module_class = AnnotatableModule
|
||||
template_dir_name = "annotatable"
|
||||
mako_template = "widgets/raw-edit.html"
|
||||
|
||||
@@ -77,6 +77,14 @@ class CapaFields(object):
|
||||
"""
|
||||
Define the possible fields for a Capa problem
|
||||
"""
|
||||
display_name = String(
|
||||
display_name="Display Name",
|
||||
help="This name appears in the horizontal navigation at the top of the page.",
|
||||
scope=Scope.settings,
|
||||
# it'd be nice to have a useful default but it screws up other things; so,
|
||||
# use display_name_with_default for those
|
||||
default="Blank Advanced Problem"
|
||||
)
|
||||
attempts = Integer(help="Number of attempts taken by the student on this problem",
|
||||
default=0, scope=Scope.user_state)
|
||||
max_attempts = Integer(
|
||||
@@ -94,7 +102,8 @@ class CapaFields(object):
|
||||
display_name="Show Answer",
|
||||
help=("Defines when to show the answer to the problem. "
|
||||
"A default value can be set in Advanced Settings."),
|
||||
scope=Scope.settings, default="closed",
|
||||
scope=Scope.settings,
|
||||
default="finished",
|
||||
values=[
|
||||
{"display_name": "Always", "value": "always"},
|
||||
{"display_name": "Answered", "value": "answered"},
|
||||
@@ -106,21 +115,24 @@ class CapaFields(object):
|
||||
)
|
||||
force_save_button = Boolean(
|
||||
help="Whether to force the save button to appear on the page",
|
||||
scope=Scope.settings, default=False
|
||||
scope=Scope.settings,
|
||||
default=False
|
||||
)
|
||||
rerandomize = Randomization(
|
||||
display_name="Randomization",
|
||||
help="Defines how often inputs are randomized when a student loads the problem. "
|
||||
"This setting only applies to problems that can have randomly generated numeric values. "
|
||||
"A default value can be set in Advanced Settings.",
|
||||
default="always", scope=Scope.settings, values=[
|
||||
"This setting only applies to problems that can have randomly generated numeric values. "
|
||||
"A default value can be set in Advanced Settings.",
|
||||
default="never",
|
||||
scope=Scope.settings,
|
||||
values=[
|
||||
{"display_name": "Always", "value": "always"},
|
||||
{"display_name": "On Reset", "value": "onreset"},
|
||||
{"display_name": "Never", "value": "never"},
|
||||
{"display_name": "Per Student", "value": "per_student"}
|
||||
]
|
||||
)
|
||||
data = String(help="XML data for the problem", scope=Scope.content)
|
||||
data = String(help="XML data for the problem", scope=Scope.content, default="<problem></problem>")
|
||||
correct_map = Dict(help="Dictionary with the correctness of current student answers",
|
||||
scope=Scope.user_state, default={})
|
||||
input_state = Dict(help="Dictionary for maintaining the state of inputtypes", scope=Scope.user_state)
|
||||
@@ -134,7 +146,7 @@ class CapaFields(object):
|
||||
values={"min": 0, "step": .1},
|
||||
scope=Scope.settings
|
||||
)
|
||||
markdown = String(help="Markdown source of this module", scope=Scope.settings)
|
||||
markdown = String(help="Markdown source of this module", default=None, scope=Scope.settings)
|
||||
source_code = String(
|
||||
help="Source code for LaTeX and Word problems. This feature is not well-supported.",
|
||||
scope=Scope.settings
|
||||
@@ -297,7 +309,13 @@ class CapaModule(CapaFields, XModule):
|
||||
d = self.get_score()
|
||||
score = d['score']
|
||||
total = d['total']
|
||||
|
||||
if total > 0:
|
||||
if self.weight is not None:
|
||||
# scale score and total by weight/total:
|
||||
score = score * self.weight / total
|
||||
total = self.weight
|
||||
|
||||
try:
|
||||
return Progress(score, total)
|
||||
except (TypeError, ValueError):
|
||||
@@ -309,11 +327,13 @@ class CapaModule(CapaFields, XModule):
|
||||
"""
|
||||
Return some html with data about the module
|
||||
"""
|
||||
progress = self.get_progress()
|
||||
return self.system.render_template('problem_ajax.html', {
|
||||
'element_id': self.location.html_id(),
|
||||
'id': self.id,
|
||||
'ajax_url': self.system.ajax_url,
|
||||
'progress': Progress.to_js_status_str(self.get_progress())
|
||||
'progress_status': Progress.to_js_status_str(progress),
|
||||
'progress_detail': Progress.to_js_detail_str(progress),
|
||||
})
|
||||
|
||||
def check_button_name(self):
|
||||
@@ -473,8 +493,7 @@ class CapaModule(CapaFields, XModule):
|
||||
"""
|
||||
Return html for the problem.
|
||||
|
||||
Adds check, reset, save buttons as necessary based on the problem config
|
||||
and state.
|
||||
Adds check, reset, save buttons as necessary based on the problem config and state.
|
||||
"""
|
||||
|
||||
try:
|
||||
@@ -504,13 +523,12 @@ class CapaModule(CapaFields, XModule):
|
||||
'reset_button': self.should_show_reset_button(),
|
||||
'save_button': self.should_show_save_button(),
|
||||
'answer_available': self.answer_available(),
|
||||
'ajax_url': self.system.ajax_url,
|
||||
'attempts_used': self.attempts,
|
||||
'attempts_allowed': self.max_attempts,
|
||||
'progress': self.get_progress(),
|
||||
}
|
||||
|
||||
html = self.system.render_template('problem.html', context)
|
||||
|
||||
if encapsulate:
|
||||
html = u'<div id="problem_{id}" class="problem" data-url="{ajax_url}">'.format(
|
||||
id=self.location.html_id(), ajax_url=self.system.ajax_url
|
||||
@@ -541,6 +559,16 @@ class CapaModule(CapaFields, XModule):
|
||||
'ungraded_response': self.handle_ungraded_response
|
||||
}
|
||||
|
||||
generic_error_message = (
|
||||
"We're sorry, there was an error with processing your request. "
|
||||
"Please try reloading your page and trying again."
|
||||
)
|
||||
|
||||
not_found_error_message = (
|
||||
"The state of this problem has changed since you loaded this page. "
|
||||
"Please refresh your page."
|
||||
)
|
||||
|
||||
if dispatch not in handlers:
|
||||
return 'Error'
|
||||
|
||||
@@ -548,15 +576,21 @@ class CapaModule(CapaFields, XModule):
|
||||
|
||||
try:
|
||||
result = handlers[dispatch](data)
|
||||
|
||||
except NotFoundError as err:
|
||||
_, _, traceback_obj = sys.exc_info()
|
||||
raise ProcessingError, (not_found_error_message, err), traceback_obj
|
||||
|
||||
except Exception as err:
|
||||
_, _, traceback_obj = sys.exc_info()
|
||||
raise ProcessingError(err.message, traceback_obj)
|
||||
raise ProcessingError, (generic_error_message, err), traceback_obj
|
||||
|
||||
after = self.get_progress()
|
||||
|
||||
result.update({
|
||||
'progress_changed': after != before,
|
||||
'progress_status': Progress.to_js_status_str(after),
|
||||
'progress_detail': Progress.to_js_detail_str(after),
|
||||
})
|
||||
|
||||
return json.dumps(result, cls=ComplexEncoder)
|
||||
@@ -587,6 +621,7 @@ class CapaModule(CapaFields, XModule):
|
||||
Problem can be completely wrong.
|
||||
Pressing RESET button makes this function to return False.
|
||||
"""
|
||||
# used by conditional module
|
||||
return self.lcp.done
|
||||
|
||||
def is_attempted(self):
|
||||
@@ -730,6 +765,7 @@ class CapaModule(CapaFields, XModule):
|
||||
"""
|
||||
return {'html': self.get_problem_html(encapsulate=False)}
|
||||
|
||||
|
||||
@staticmethod
|
||||
def make_dict_of_responses(data):
|
||||
"""
|
||||
@@ -749,6 +785,13 @@ class CapaModule(CapaFields, XModule):
|
||||
then the output dict would contain {'1': ['test'] }
|
||||
(the value is a list).
|
||||
|
||||
Some other inputs such as ChoiceTextInput expect a dict of values in the returned
|
||||
dict If the key ends with '{}' then we will assume that the value is a json
|
||||
encoded dict and deserialize it.
|
||||
For example, if the `data` dict contains {'input_1{}': '{"1_2_1": 1}'}
|
||||
then the output dict would contain {'1': {"1_2_1": 1} }
|
||||
(the value is a dictionary)
|
||||
|
||||
Raises an exception if:
|
||||
|
||||
-A key in the `data` dictionary does not contain at least one underscore
|
||||
@@ -775,11 +818,22 @@ class CapaModule(CapaFields, XModule):
|
||||
# the same form input (e.g. checkbox inputs). The convention is that
|
||||
# if the name ends with '[]' (which looks like an array), then the
|
||||
# answer will be an array.
|
||||
# if the name ends with '{}' (Which looks like a dict),
|
||||
# then the answer will be a dict
|
||||
is_list_key = name.endswith('[]')
|
||||
name = name[:-2] if is_list_key else name
|
||||
is_dict_key = name.endswith('{}')
|
||||
name = name[:-2] if is_list_key or is_dict_key else name
|
||||
|
||||
if is_list_key:
|
||||
val = data.getlist(key)
|
||||
elif is_dict_key:
|
||||
try:
|
||||
val = json.loads(data[key])
|
||||
# If the submission wasn't deserializable, raise an error.
|
||||
except(KeyError, ValueError):
|
||||
raise ValueError(
|
||||
u"Invalid submission: {val} for {key}".format(val=data[key], key=key)
|
||||
)
|
||||
else:
|
||||
val = data[key]
|
||||
|
||||
@@ -1072,8 +1126,12 @@ class CapaDescriptor(CapaFields, RawDescriptor):
|
||||
mako_template = "widgets/problem-edit.html"
|
||||
js = {'coffee': [resource_string(__name__, 'js/src/problem/edit.coffee')]}
|
||||
js_module_name = "MarkdownEditingDescriptor"
|
||||
css = {'scss': [resource_string(__name__, 'css/editor/edit.scss'),
|
||||
resource_string(__name__, 'css/problem/edit.scss')]}
|
||||
css = {
|
||||
'scss': [
|
||||
resource_string(__name__, 'css/editor/edit.scss'),
|
||||
resource_string(__name__, 'css/problem/edit.scss')
|
||||
]
|
||||
}
|
||||
|
||||
# Capa modules have some additional metadata:
|
||||
# TODO (vshnayder): do problems have any other metadata? Do they
|
||||
|
||||
@@ -9,10 +9,11 @@ from xblock.core import Integer, Scope, String, List, Float, Boolean
|
||||
from xmodule.open_ended_grading_classes.combined_open_ended_modulev1 import CombinedOpenEndedV1Module, CombinedOpenEndedV1Descriptor
|
||||
from collections import namedtuple
|
||||
from .fields import Date
|
||||
import textwrap
|
||||
|
||||
log = logging.getLogger("mitx.courseware")
|
||||
|
||||
V1_SETTINGS_ATTRIBUTES = ["display_name", "attempts", "is_graded", "accept_file_upload",
|
||||
V1_SETTINGS_ATTRIBUTES = ["display_name", "max_attempts", "graded", "accept_file_upload",
|
||||
"skip_spelling_checks", "due", "graceperiod", "weight"]
|
||||
|
||||
V1_STUDENT_ATTRIBUTES = ["current_task_number", "task_states", "state",
|
||||
@@ -27,6 +28,126 @@ VERSION_TUPLES = {
|
||||
}
|
||||
|
||||
DEFAULT_VERSION = 1
|
||||
DEFAULT_DATA = textwrap.dedent("""\
|
||||
<combinedopenended>
|
||||
<prompt>
|
||||
<h3>Censorship in the Libraries</h3>
|
||||
|
||||
<p>'All of us can think of a book that we hope none of our children or any other children have taken off the shelf. But if I have the right to remove that book from the shelf -- that work I abhor -- then you also have exactly the same right and so does everyone else. And then we have no books left on the shelf for any of us.' --Katherine Paterson, Author
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Write a persuasive essay to a newspaper reflecting your vies on censorship in libraries. Do you believe that certain materials, such as books, music, movies, magazines, etc., should be removed from the shelves if they are found offensive? Support your position with convincing arguments from your own experience, observations, and/or reading.
|
||||
</p>
|
||||
|
||||
</prompt>
|
||||
<rubric>
|
||||
<rubric>
|
||||
<category>
|
||||
<description>
|
||||
Ideas
|
||||
</description>
|
||||
<option>
|
||||
Difficult for the reader to discern the main idea. Too brief or too repetitive to establish or maintain a focus.
|
||||
</option>
|
||||
<option>
|
||||
Attempts a main idea. Sometimes loses focus or ineffectively displays focus.
|
||||
</option>
|
||||
<option>
|
||||
Presents a unifying theme or main idea, but may include minor tangents. Stays somewhat focused on topic and task.
|
||||
</option>
|
||||
<option>
|
||||
Presents a unifying theme or main idea without going off on tangents. Stays completely focused on topic and task.
|
||||
</option>
|
||||
</category>
|
||||
<category>
|
||||
<description>
|
||||
Content
|
||||
</description>
|
||||
<option>
|
||||
Includes little information with few or no details or unrelated details. Unsuccessful in attempts to explore any facets of the topic.
|
||||
</option>
|
||||
<option>
|
||||
Includes little information and few or no details. Explores only one or two facets of the topic.
|
||||
</option>
|
||||
<option>
|
||||
Includes sufficient information and supporting details. (Details may not be fully developed; ideas may be listed.) Explores some facets of the topic.
|
||||
</option>
|
||||
<option>
|
||||
Includes in-depth information and exceptional supporting details that are fully developed. Explores all facets of the topic.
|
||||
</option>
|
||||
</category>
|
||||
<category>
|
||||
<description>
|
||||
Organization
|
||||
</description>
|
||||
<option>
|
||||
Ideas organized illogically, transitions weak, and response difficult to follow.
|
||||
</option>
|
||||
<option>
|
||||
Attempts to logically organize ideas. Attempts to progress in an order that enhances meaning, and demonstrates use of transitions.
|
||||
</option>
|
||||
<option>
|
||||
Ideas organized logically. Progresses in an order that enhances meaning. Includes smooth transitions.
|
||||
</option>
|
||||
</category>
|
||||
<category>
|
||||
<description>
|
||||
Style
|
||||
</description>
|
||||
<option>
|
||||
Contains limited vocabulary, with many words used incorrectly. Demonstrates problems with sentence patterns.
|
||||
</option>
|
||||
<option>
|
||||
Contains basic vocabulary, with words that are predictable and common. Contains mostly simple sentences (although there may be an attempt at more varied sentence patterns).
|
||||
</option>
|
||||
<option>
|
||||
Includes vocabulary to make explanations detailed and precise. Includes varied sentence patterns, including complex sentences.
|
||||
</option>
|
||||
</category>
|
||||
<category>
|
||||
<description>
|
||||
Voice
|
||||
</description>
|
||||
<option>
|
||||
Demonstrates language and tone that may be inappropriate to task and reader.
|
||||
</option>
|
||||
<option>
|
||||
Demonstrates an attempt to adjust language and tone to task and reader.
|
||||
</option>
|
||||
<option>
|
||||
Demonstrates effective adjustment of language and tone to task and reader.
|
||||
</option>
|
||||
|
||||
</category>
|
||||
</rubric>
|
||||
</rubric>
|
||||
|
||||
<task>
|
||||
<selfassessment/></task>
|
||||
<task>
|
||||
|
||||
<openended min_score_to_attempt="4" max_score_to_attempt="12" >
|
||||
<openendedparam>
|
||||
<initial_display>Enter essay here.</initial_display>
|
||||
<answer_display>This is the answer.</answer_display>
|
||||
<grader_payload>{"grader_settings" : "ml_grading.conf", "problem_id" : "6.002x/Welcome/OETest"}</grader_payload>
|
||||
</openendedparam>
|
||||
</openended>
|
||||
</task>
|
||||
<task>
|
||||
|
||||
<openended min_score_to_attempt="9" max_score_to_attempt="12" >
|
||||
<openendedparam>
|
||||
<initial_display>Enter essay here.</initial_display>
|
||||
<answer_display>This is the answer.</answer_display>
|
||||
<grader_payload>{"grader_settings" : "peer_grading.conf", "problem_id" : "6.002x/Welcome/OETest"}</grader_payload>
|
||||
</openendedparam>
|
||||
</openended>
|
||||
</task>
|
||||
|
||||
</combinedopenended>
|
||||
""")
|
||||
|
||||
|
||||
class VersionInteger(Integer):
|
||||
@@ -51,47 +172,122 @@ class CombinedOpenEndedFields(object):
|
||||
display_name = String(
|
||||
display_name="Display Name",
|
||||
help="This name appears in the horizontal navigation at the top of the page.",
|
||||
default="Open Ended Grading", scope=Scope.settings
|
||||
default="Open Response Assessment",
|
||||
scope=Scope.settings
|
||||
)
|
||||
current_task_number = Integer(help="Current task that the student is on.", default=0, scope=Scope.user_state)
|
||||
task_states = List(help="List of state dictionaries of each task within this module.", scope=Scope.user_state)
|
||||
state = String(help="Which step within the current task that the student is on.", default="initial",
|
||||
scope=Scope.user_state)
|
||||
student_attempts = Integer(help="Number of attempts taken by the student on this problem", default=0,
|
||||
scope=Scope.user_state)
|
||||
ready_to_reset = Boolean(
|
||||
help="If the problem is ready to be reset or not.", default=False,
|
||||
current_task_number = Integer(
|
||||
help="Current task that the student is on.",
|
||||
default=0,
|
||||
scope=Scope.user_state
|
||||
)
|
||||
attempts = Integer(
|
||||
display_name="Maximum Attempts",
|
||||
help="The number of times the student can try to answer this problem.", default=1,
|
||||
scope=Scope.settings, values={"min" : 1 }
|
||||
task_states = List(
|
||||
help="List of state dictionaries of each task within this module.",
|
||||
scope=Scope.user_state
|
||||
)
|
||||
state = String(
|
||||
help="Which step within the current task that the student is on.",
|
||||
default="initial",
|
||||
scope=Scope.user_state
|
||||
)
|
||||
graded = Boolean(
|
||||
display_name="Graded",
|
||||
help='Defines whether the student gets credit for grading this problem.',
|
||||
default=False,
|
||||
scope=Scope.settings
|
||||
)
|
||||
student_attempts = Integer(
|
||||
help="Number of attempts taken by the student on this problem",
|
||||
default=0,
|
||||
scope=Scope.user_state
|
||||
)
|
||||
ready_to_reset = Boolean(
|
||||
help="If the problem is ready to be reset or not.",
|
||||
default=False,
|
||||
scope=Scope.user_state
|
||||
)
|
||||
max_attempts = Integer(
|
||||
display_name="Maximum Attempts",
|
||||
help="The number of times the student can try to answer this problem.",
|
||||
default=1,
|
||||
scope=Scope.settings,
|
||||
values={"min" : 1 }
|
||||
)
|
||||
is_graded = Boolean(display_name="Graded", help="Whether or not the problem is graded.", default=False, scope=Scope.settings)
|
||||
accept_file_upload = Boolean(
|
||||
display_name="Allow File Uploads",
|
||||
help="Whether or not the student can submit files as a response.", default=False, scope=Scope.settings
|
||||
help="Whether or not the student can submit files as a response.",
|
||||
default=False,
|
||||
scope=Scope.settings
|
||||
)
|
||||
skip_spelling_checks = Boolean(
|
||||
display_name="Disable Quality Filter",
|
||||
help="If False, the Quality Filter is enabled and submissions with poor spelling, short length, or poor grammar will not be peer reviewed.",
|
||||
default=False, scope=Scope.settings
|
||||
default=False,
|
||||
scope=Scope.settings
|
||||
)
|
||||
due = Date(
|
||||
help="Date that this problem is due by",
|
||||
default=None,
|
||||
scope=Scope.settings
|
||||
)
|
||||
due = Date(help="Date that this problem is due by", default=None, scope=Scope.settings)
|
||||
graceperiod = String(
|
||||
help="Amount of time after the due date that submissions will be accepted",
|
||||
default=None,
|
||||
scope=Scope.settings
|
||||
)
|
||||
version = VersionInteger(help="Current version number", default=DEFAULT_VERSION, scope=Scope.settings)
|
||||
data = String(help="XML data for the problem", scope=Scope.content)
|
||||
data = String(help="XML data for the problem", scope=Scope.content,
|
||||
default=DEFAULT_DATA)
|
||||
weight = Float(
|
||||
display_name="Problem Weight",
|
||||
help="Defines the number of points each problem is worth. If the value is not set, each problem is worth one point.",
|
||||
scope=Scope.settings, values={"min" : 0 , "step": ".1"}
|
||||
scope=Scope.settings,
|
||||
values={"min" : 0 , "step": ".1"},
|
||||
default=1
|
||||
)
|
||||
markdown = String(
|
||||
help="Markdown source of this module",
|
||||
default=textwrap.dedent("""\
|
||||
[prompt]
|
||||
<h3>Censorship in the Libraries</h3>
|
||||
|
||||
<p>'All of us can think of a book that we hope none of our children or any other children have taken off the shelf. But if I have the right to remove that book from the shelf -- that work I abhor -- then you also have exactly the same right and so does everyone else. And then we have no books left on the shelf for any of us.' --Katherine Paterson, Author
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Write a persuasive essay to a newspaper reflecting your vies on censorship in libraries. Do you believe that certain materials, such as books, music, movies, magazines, etc., should be removed from the shelves if they are found offensive? Support your position with convincing arguments from your own experience, observations, and/or reading.
|
||||
</p>
|
||||
[prompt]
|
||||
[rubric]
|
||||
+ Ideas
|
||||
- Difficult for the reader to discern the main idea. Too brief or too repetitive to establish or maintain a focus.
|
||||
- Attempts a main idea. Sometimes loses focus or ineffectively displays focus.
|
||||
- Presents a unifying theme or main idea, but may include minor tangents. Stays somewhat focused on topic and task.
|
||||
- Presents a unifying theme or main idea without going off on tangents. Stays completely focused on topic and task.
|
||||
+ Content
|
||||
- Includes little information with few or no details or unrelated details. Unsuccessful in attempts to explore any facets of the topic.
|
||||
- Includes little information and few or no details. Explores only one or two facets of the topic.
|
||||
- Includes sufficient information and supporting details. (Details may not be fully developed; ideas may be listed.) Explores some facets of the topic.
|
||||
- Includes in-depth information and exceptional supporting details that are fully developed. Explores all facets of the topic.
|
||||
+ Organization
|
||||
- Ideas organized illogically, transitions weak, and response difficult to follow.
|
||||
- Attempts to logically organize ideas. Attempts to progress in an order that enhances meaning, and demonstrates use of transitions.
|
||||
- Ideas organized logically. Progresses in an order that enhances meaning. Includes smooth transitions.
|
||||
+ Style
|
||||
- Contains limited vocabulary, with many words used incorrectly. Demonstrates problems with sentence patterns.
|
||||
- Contains basic vocabulary, with words that are predictable and common. Contains mostly simple sentences (although there may be an attempt at more varied sentence patterns).
|
||||
- Includes vocabulary to make explanations detailed and precise. Includes varied sentence patterns, including complex sentences.
|
||||
+ Voice
|
||||
- Demonstrates language and tone that may be inappropriate to task and reader.
|
||||
- Demonstrates an attempt to adjust language and tone to task and reader.
|
||||
- Demonstrates effective adjustment of language and tone to task and reader.
|
||||
[rubric]
|
||||
[tasks]
|
||||
(Self), ({4-12}AI), ({9-12}Peer)
|
||||
[tasks]
|
||||
|
||||
"""),
|
||||
scope=Scope.settings
|
||||
)
|
||||
markdown = String(help="Markdown source of this module", scope=Scope.settings)
|
||||
|
||||
|
||||
class CombinedOpenEndedModule(CombinedOpenEndedFields, XModule):
|
||||
@@ -143,37 +339,9 @@ class CombinedOpenEndedModule(CombinedOpenEndedFields, XModule):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""
|
||||
Definition file should have one or many task blocks, a rubric block, and a prompt block:
|
||||
Definition file should have one or many task blocks, a rubric block, and a prompt block.
|
||||
|
||||
Sample file:
|
||||
<combinedopenended attempts="10000">
|
||||
<rubric>
|
||||
Blah blah rubric.
|
||||
</rubric>
|
||||
<prompt>
|
||||
Some prompt.
|
||||
</prompt>
|
||||
<task>
|
||||
<selfassessment>
|
||||
<hintprompt>
|
||||
What hint about this problem would you give to someone?
|
||||
</hintprompt>
|
||||
<submitmessage>
|
||||
Save Succcesful. Thanks for participating!
|
||||
</submitmessage>
|
||||
</selfassessment>
|
||||
</task>
|
||||
<task>
|
||||
<openended min_score_to_attempt="1" max_score_to_attempt="1">
|
||||
<openendedparam>
|
||||
<initial_display>Enter essay here.</initial_display>
|
||||
<answer_display>This is the answer.</answer_display>
|
||||
<grader_payload>{"grader_settings" : "ml_grading.conf",
|
||||
"problem_id" : "6.002x/Welcome/OETest"}</grader_payload>
|
||||
</openendedparam>
|
||||
</openended>
|
||||
</task>
|
||||
</combinedopenended>
|
||||
See DEFAULT_DATA for a sample.
|
||||
|
||||
"""
|
||||
XModule.__init__(self, *args, **kwargs)
|
||||
@@ -254,6 +422,11 @@ class CombinedOpenEndedDescriptor(CombinedOpenEndedFields, RawDescriptor):
|
||||
js_module_name = "OpenEndedMarkdownEditingDescriptor"
|
||||
css = {'scss': [resource_string(__name__, 'css/editor/edit.scss'), resource_string(__name__, 'css/combinedopenended/edit.scss')]}
|
||||
|
||||
metadata_translations = {
|
||||
'is_graded': 'graded',
|
||||
'attempts': 'max_attempts',
|
||||
}
|
||||
|
||||
def get_context(self):
|
||||
_context = RawDescriptor.get_context(self)
|
||||
_context.update({'markdown': self.markdown,
|
||||
|
||||
@@ -15,6 +15,7 @@ import json
|
||||
|
||||
from xblock.core import Scope, List, String, Dict, Boolean
|
||||
from .fields import Date
|
||||
from xmodule.modulestore.locator import CourseLocator
|
||||
from django.utils.timezone import UTC
|
||||
from xmodule.util import date_utils
|
||||
|
||||
@@ -145,16 +146,55 @@ class TextbookList(List):
|
||||
|
||||
|
||||
class CourseFields(object):
|
||||
textbooks = TextbookList(help="List of pairs of (title, url) for textbooks used in this course", scope=Scope.content)
|
||||
textbooks = TextbookList(help="List of pairs of (title, url) for textbooks used in this course",
|
||||
default=[], scope=Scope.content)
|
||||
wiki_slug = String(help="Slug that points to the wiki for this course", scope=Scope.content)
|
||||
enrollment_start = Date(help="Date that enrollment for this class is opened", scope=Scope.settings)
|
||||
enrollment_end = Date(help="Date that enrollment for this class is closed", scope=Scope.settings)
|
||||
start = Date(help="Start time when this module is visible", scope=Scope.settings)
|
||||
start = Date(help="Start time when this module is visible",
|
||||
# using now(UTC()) resulted in fractional seconds which screwed up comparisons and anyway w/b the
|
||||
# time of first invocation of this stmt on the server
|
||||
default=datetime.fromtimestamp(0, UTC()),
|
||||
scope=Scope.settings)
|
||||
end = Date(help="Date that this class ends", scope=Scope.settings)
|
||||
advertised_start = String(help="Date that this course is advertised to start", scope=Scope.settings)
|
||||
grading_policy = Dict(help="Grading policy definition for this class", scope=Scope.content)
|
||||
grading_policy = Dict(help="Grading policy definition for this class",
|
||||
default={"GRADER": [
|
||||
{
|
||||
"type": "Homework",
|
||||
"min_count": 12,
|
||||
"drop_count": 2,
|
||||
"short_label": "HW",
|
||||
"weight": 0.15
|
||||
},
|
||||
{
|
||||
"type": "Lab",
|
||||
"min_count": 12,
|
||||
"drop_count": 2,
|
||||
"weight": 0.15
|
||||
},
|
||||
{
|
||||
"type": "Midterm Exam",
|
||||
"short_label": "Midterm",
|
||||
"min_count": 1,
|
||||
"drop_count": 0,
|
||||
"weight": 0.3
|
||||
},
|
||||
{
|
||||
"type": "Final Exam",
|
||||
"short_label": "Final",
|
||||
"min_count": 1,
|
||||
"drop_count": 0,
|
||||
"weight": 0.4
|
||||
}
|
||||
],
|
||||
"GRADE_CUTOFFS": {
|
||||
"Pass": 0.5
|
||||
}},
|
||||
scope=Scope.content)
|
||||
show_calculator = Boolean(help="Whether to show the calculator in this course", default=False, scope=Scope.settings)
|
||||
display_name = String(help="Display name for this module", scope=Scope.settings)
|
||||
display_name = String(help="Display name for this module", default="Empty", display_name="Display Name", scope=Scope.settings)
|
||||
show_chat = Boolean(help="Whether to show the chat widget in this course", default=False, scope=Scope.settings)
|
||||
tabs = List(help="List of tabs to enable in this course", scope=Scope.settings)
|
||||
end_of_course_survey_url = String(help="Url for the end-of-course survey", scope=Scope.settings)
|
||||
discussion_blackouts = List(help="List of pairs of start/end dates for discussion blackouts", scope=Scope.settings)
|
||||
@@ -175,7 +215,125 @@ class CourseFields(object):
|
||||
allow_anonymous_to_peers = Boolean(scope=Scope.settings, default=False)
|
||||
advanced_modules = List(help="Beta modules used in your course", scope=Scope.settings)
|
||||
has_children = True
|
||||
checklists = List(scope=Scope.settings)
|
||||
checklists = List(scope=Scope.settings,
|
||||
default=[
|
||||
{"short_description" : "Getting Started With Studio",
|
||||
"items" : [{"short_description": "Add Course Team Members",
|
||||
"long_description": "Grant your collaborators permission to edit your course so you can work together.",
|
||||
"is_checked": False,
|
||||
"action_url": "ManageUsers",
|
||||
"action_text": "Edit Course Team",
|
||||
"action_external": False},
|
||||
{"short_description": "Set Important Dates for Your Course",
|
||||
"long_description": "Establish your course's student enrollment and launch dates on the Schedule and Details page.",
|
||||
"is_checked": False,
|
||||
"action_url": "SettingsDetails",
|
||||
"action_text": "Edit Course Details & Schedule",
|
||||
"action_external": False},
|
||||
{"short_description": "Draft Your Course's Grading Policy",
|
||||
"long_description": "Set up your assignment types and grading policy even if you haven't created all your assignments.",
|
||||
"is_checked": False,
|
||||
"action_url": "SettingsGrading",
|
||||
"action_text": "Edit Grading Settings",
|
||||
"action_external": False},
|
||||
{"short_description": "Explore the Other Studio Checklists",
|
||||
"long_description": "Discover other available course authoring tools, and find help when you need it.",
|
||||
"is_checked": False,
|
||||
"action_url": "",
|
||||
"action_text": "",
|
||||
"action_external": False}]
|
||||
},
|
||||
{"short_description" : "Draft a Rough Course Outline",
|
||||
"items" : [{"short_description": "Create Your First Section and Subsection",
|
||||
"long_description": "Use your course outline to build your first Section and Subsection.",
|
||||
"is_checked": False,
|
||||
"action_url": "CourseOutline",
|
||||
"action_text": "Edit Course Outline",
|
||||
"action_external": False},
|
||||
{"short_description": "Set Section Release Dates",
|
||||
"long_description": "Specify the release dates for each Section in your course. Sections become visible to students on their release dates.",
|
||||
"is_checked": False,
|
||||
"action_url": "CourseOutline",
|
||||
"action_text": "Edit Course Outline",
|
||||
"action_external": False},
|
||||
{"short_description": "Designate a Subsection as Graded",
|
||||
"long_description": "Set a Subsection to be graded as a specific assignment type. Assignments within graded Subsections count toward a student's final grade.",
|
||||
"is_checked": False,
|
||||
"action_url": "CourseOutline",
|
||||
"action_text": "Edit Course Outline",
|
||||
"action_external": False},
|
||||
{"short_description": "Reordering Course Content",
|
||||
"long_description": "Use drag and drop to reorder the content in your course.",
|
||||
"is_checked": False,
|
||||
"action_url": "CourseOutline",
|
||||
"action_text": "Edit Course Outline",
|
||||
"action_external": False},
|
||||
{"short_description": "Renaming Sections",
|
||||
"long_description": "Rename Sections by clicking the Section name from the Course Outline.",
|
||||
"is_checked": False,
|
||||
"action_url": "CourseOutline",
|
||||
"action_text": "Edit Course Outline",
|
||||
"action_external": False},
|
||||
{"short_description": "Deleting Course Content",
|
||||
"long_description": "Delete Sections, Subsections, or Units you don't need anymore. Be careful, as there is no Undo function.",
|
||||
"is_checked": False,
|
||||
"action_url": "CourseOutline",
|
||||
"action_text": "Edit Course Outline",
|
||||
"action_external": False},
|
||||
{"short_description": "Add an Instructor-Only Section to Your Outline",
|
||||
"long_description": "Some course authors find using a section for unsorted, in-progress work useful. To do this, create a section and set the release date to the distant future.",
|
||||
"is_checked": False,
|
||||
"action_url": "CourseOutline",
|
||||
"action_text": "Edit Course Outline",
|
||||
"action_external": False}]
|
||||
},
|
||||
{"short_description" : "Explore edX's Support Tools",
|
||||
"items" : [{"short_description": "Explore the Studio Help Forum",
|
||||
"long_description": "Access the Studio Help forum from the menu that appears when you click your user name in the top right corner of Studio.",
|
||||
"is_checked": False,
|
||||
"action_url": "http://help.edge.edx.org/",
|
||||
"action_text": "Visit Studio Help",
|
||||
"action_external": True},
|
||||
{"short_description": "Enroll in edX 101",
|
||||
"long_description": "Register for edX 101, edX's primer for course creation.",
|
||||
"is_checked": False,
|
||||
"action_url": "https://edge.edx.org/courses/edX/edX101/How_to_Create_an_edX_Course/about",
|
||||
"action_text": "Register for edX 101",
|
||||
"action_external": True},
|
||||
{"short_description": "Download the Studio Documentation",
|
||||
"long_description": "Download the searchable Studio reference documentation in PDF form.",
|
||||
"is_checked": False,
|
||||
"action_url": "http://files.edx.org/Getting_Started_with_Studio.pdf",
|
||||
"action_text": "Download Documentation",
|
||||
"action_external": True}]
|
||||
},
|
||||
{"short_description" : "Draft Your Course About Page",
|
||||
"items" : [{"short_description": "Draft a Course Description",
|
||||
"long_description": "Courses on edX have an About page that includes a course video, description, and more. Draft the text students will read before deciding to enroll in your course.",
|
||||
"is_checked": False,
|
||||
"action_url": "SettingsDetails",
|
||||
"action_text": "Edit Course Schedule & Details",
|
||||
"action_external": False},
|
||||
{"short_description": "Add Staff Bios",
|
||||
"long_description": "Showing prospective students who their instructor will be is helpful. Include staff bios on the course About page.",
|
||||
"is_checked": False,
|
||||
"action_url": "SettingsDetails",
|
||||
"action_text": "Edit Course Schedule & Details",
|
||||
"action_external": False},
|
||||
{"short_description": "Add Course FAQs",
|
||||
"long_description": "Include a short list of frequently asked questions about your course.",
|
||||
"is_checked": False,
|
||||
"action_url": "SettingsDetails",
|
||||
"action_text": "Edit Course Schedule & Details",
|
||||
"action_external": False},
|
||||
{"short_description": "Add Course Prerequisites",
|
||||
"long_description": "Let students know what knowledge and/or skills they should have before they enroll in your course.",
|
||||
"is_checked": False,
|
||||
"action_url": "SettingsDetails",
|
||||
"action_text": "Edit Course Schedule & Details",
|
||||
"action_external": False}]
|
||||
}
|
||||
])
|
||||
info_sidebar_name = String(scope=Scope.settings, default='Course Handouts')
|
||||
show_timezone = Boolean(help="True if timezones should be shown on dates in the courseware", scope=Scope.settings, default=True)
|
||||
enrollment_domain = String(help="External login method associated with user accounts allowed to register in course",
|
||||
@@ -208,8 +366,6 @@ class CourseFields(object):
|
||||
class CourseDescriptor(CourseFields, SequenceDescriptor):
|
||||
module_class = SequenceModule
|
||||
|
||||
template_dir_name = 'course'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""
|
||||
Expects the same arguments as XModuleDescriptor.__init__
|
||||
@@ -217,21 +373,22 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
|
||||
super(CourseDescriptor, self).__init__(*args, **kwargs)
|
||||
|
||||
if self.wiki_slug is None:
|
||||
self.wiki_slug = self.location.course
|
||||
if isinstance(self.location, Location):
|
||||
self.wiki_slug = self.location.course
|
||||
elif isinstance(self.location, CourseLocator):
|
||||
self.wiki_slug = self.location.course_id or self.display_name
|
||||
|
||||
msg = None
|
||||
if self.start is None:
|
||||
msg = "Course loaded without a valid start date. id = %s" % self.id
|
||||
self.start = datetime.now(UTC())
|
||||
log.critical(msg)
|
||||
self.system.error_tracker(msg)
|
||||
|
||||
# NOTE: relies on the modulestore to call set_grading_policy() right after
|
||||
# init. (Modulestore is in charge of figuring out where to load the policy from)
|
||||
|
||||
# NOTE (THK): This is a last-minute addition for Fall 2012 launch to dynamically
|
||||
# disable the syllabus content for courses that do not provide a syllabus
|
||||
self.syllabus_present = self.system.resources_fs.exists(path('syllabus'))
|
||||
if self.system.resources_fs is None:
|
||||
self.syllabus_present = False
|
||||
else:
|
||||
self.syllabus_present = self.system.resources_fs.exists(path('syllabus'))
|
||||
self._grading_policy = {}
|
||||
|
||||
self.set_grading_policy(self.grading_policy)
|
||||
@@ -252,42 +409,33 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
|
||||
log.error(msg)
|
||||
continue
|
||||
|
||||
def default_grading_policy(self):
|
||||
"""
|
||||
Return a dict which is a copy of the default grading policy
|
||||
"""
|
||||
return {"GRADER": [
|
||||
{
|
||||
"type": "Homework",
|
||||
"min_count": 12,
|
||||
"drop_count": 2,
|
||||
"short_label": "HW",
|
||||
"weight": 0.15
|
||||
},
|
||||
{
|
||||
"type": "Lab",
|
||||
"min_count": 12,
|
||||
"drop_count": 2,
|
||||
"weight": 0.15
|
||||
},
|
||||
{
|
||||
"type": "Midterm Exam",
|
||||
"short_label": "Midterm",
|
||||
"min_count": 1,
|
||||
"drop_count": 0,
|
||||
"weight": 0.3
|
||||
},
|
||||
{
|
||||
"type": "Final Exam",
|
||||
"short_label": "Final",
|
||||
"min_count": 1,
|
||||
"drop_count": 0,
|
||||
"weight": 0.4
|
||||
}
|
||||
],
|
||||
"GRADE_CUTOFFS": {
|
||||
"Pass": 0.5
|
||||
}}
|
||||
# TODO check that this is still needed here and can't be by defaults.
|
||||
if not self.tabs:
|
||||
# When calling the various _tab methods, can omit the 'type':'blah' from the
|
||||
# first arg, since that's only used for dispatch
|
||||
tabs = []
|
||||
tabs.append({'type': 'courseware'})
|
||||
tabs.append({'type': 'course_info', 'name': 'Course Info'})
|
||||
|
||||
if self.syllabus_present:
|
||||
tabs.append({'type': 'syllabus'})
|
||||
|
||||
tabs.append({'type': 'textbooks'})
|
||||
|
||||
# # 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 self.discussion_link:
|
||||
tabs.append({'type': 'external_discussion', 'link': self.discussion_link})
|
||||
else:
|
||||
tabs.append({'type': 'discussion', 'name': 'Discussion'})
|
||||
|
||||
tabs.append({'type': 'wiki', 'name': 'Wiki'})
|
||||
|
||||
if not self.hide_progress_tab:
|
||||
tabs.append({'type': 'progress', 'name': 'Progress'})
|
||||
|
||||
self.tabs = tabs
|
||||
|
||||
def set_grading_policy(self, course_policy):
|
||||
"""
|
||||
@@ -298,7 +446,13 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
|
||||
course_policy = {}
|
||||
|
||||
# Load the global settings as a dictionary
|
||||
grading_policy = self.default_grading_policy()
|
||||
grading_policy = self.grading_policy
|
||||
# BOY DO I HATE THIS grading_policy CODE ACROBATICS YET HERE I ADD MORE (dhm)--this fixes things persisted w/
|
||||
# defective grading policy values (but not None)
|
||||
if 'GRADER' not in grading_policy:
|
||||
grading_policy['GRADER'] = CourseFields.grading_policy.default['GRADER']
|
||||
if 'GRADE_CUTOFFS' not in grading_policy:
|
||||
grading_policy['GRADE_CUTOFFS'] = CourseFields.grading_policy.default['GRADE_CUTOFFS']
|
||||
|
||||
# Override any global settings with the course settings
|
||||
grading_policy.update(course_policy)
|
||||
@@ -354,10 +508,6 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
|
||||
system.error_tracker("Unable to decode grading policy as json")
|
||||
policy = {}
|
||||
|
||||
# cdodge: import the grading policy information that is on disk and put into the
|
||||
# descriptor 'definition' bucket as a dictionary so that it is persisted in the DB
|
||||
instance.grading_policy = policy
|
||||
|
||||
# now set the current instance. set_grading_policy() will apply some inheritance rules
|
||||
instance.set_grading_policy(policy)
|
||||
|
||||
@@ -661,6 +811,7 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
|
||||
if isinstance(self.advertised_start, basestring):
|
||||
return try_parse_iso_8601(self.advertised_start)
|
||||
elif self.advertised_start is None and self.start is None:
|
||||
# TODO this is an impossible state since the init function forces start to have a value
|
||||
return 'TBD'
|
||||
else:
|
||||
return (self.advertised_start or self.start).strftime("%b %d, %Y")
|
||||
|
||||
@@ -3,6 +3,7 @@ h2 {
|
||||
margin-bottom: 15px;
|
||||
|
||||
&.problem-header {
|
||||
display: inline-block;
|
||||
section.staff {
|
||||
margin-top: 30px;
|
||||
font-size: 80%;
|
||||
@@ -28,6 +29,13 @@ iframe[seamless]{
|
||||
color: darken($error-red, 11%);
|
||||
}
|
||||
|
||||
section.problem-progress {
|
||||
display: inline-block;
|
||||
color: #999;
|
||||
font-size: em(16);
|
||||
font-weight: 100;
|
||||
padding-left: 5px;
|
||||
}
|
||||
|
||||
section.problem {
|
||||
@media print {
|
||||
@@ -929,4 +937,32 @@ section.problem {
|
||||
}
|
||||
}
|
||||
}
|
||||
.choicetextgroup{
|
||||
input[type="text"]{
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
@extend .choicegroup;
|
||||
|
||||
label.choicetextgroup_correct, section.choicetextgroup_correct{
|
||||
@extend label.choicegroup_correct;
|
||||
|
||||
input[type="text"] {
|
||||
border-color: green;
|
||||
}
|
||||
}
|
||||
|
||||
label.choicetextgroup_incorrect, section.choicetextgroup_incorrect{
|
||||
@extend label.choicegroup_incorrect;
|
||||
}
|
||||
|
||||
label.choicetextgroup_show_correct, section.choicetextgroup_show_correct{
|
||||
&:after{
|
||||
content: url('../images/correct-icon.png');
|
||||
margin-left:15px;
|
||||
}
|
||||
}
|
||||
span.mock_label{
|
||||
cursor : default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,17 +4,27 @@ from xmodule.x_module import XModule
|
||||
from xmodule.raw_module import RawDescriptor
|
||||
from xmodule.editing_module import MetadataOnlyEditingDescriptor
|
||||
from xblock.core import String, Scope
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
class DiscussionFields(object):
|
||||
discussion_id = String(scope=Scope.settings)
|
||||
discussion_id = String(scope=Scope.settings, default="$$GUID$$")
|
||||
display_name = String(
|
||||
display_name="Display Name",
|
||||
help="Display name for this module",
|
||||
default="Discussion Tag",
|
||||
scope=Scope.settings)
|
||||
data = String(help="XML data for the problem", scope=Scope.content,
|
||||
default="<discussion></discussion>")
|
||||
discussion_category = String(
|
||||
display_name="Category",
|
||||
default="Week 1",
|
||||
help="A category name for the discussion. This name appears in the left pane of the discussion forum for the course.",
|
||||
scope=Scope.settings
|
||||
)
|
||||
discussion_target = String(
|
||||
display_name="Subcategory",
|
||||
default="Topic-Level Student-Visible Label",
|
||||
help="A subcategory name for the discussion. This name appears in the left pane of the discussion forum for the course.",
|
||||
scope=Scope.settings
|
||||
)
|
||||
@@ -36,9 +46,15 @@ class DiscussionModule(DiscussionFields, XModule):
|
||||
|
||||
|
||||
class DiscussionDescriptor(DiscussionFields, MetadataOnlyEditingDescriptor, RawDescriptor):
|
||||
module_class = DiscussionModule
|
||||
template_dir_name = "discussion"
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(DiscussionDescriptor, self).__init__(*args, **kwargs)
|
||||
# is this too late? i.e., will it get persisted and stay static w/ the first value
|
||||
# any code references. I believe so.
|
||||
if self.discussion_id == '$$GUID$$':
|
||||
self.discussion_id = uuid4().hex
|
||||
|
||||
module_class = DiscussionModule
|
||||
# The discussion XML format uses `id` and `for` attributes,
|
||||
# but these would overload other module attributes, so we prefix them
|
||||
# for actual use in the code
|
||||
|
||||
@@ -79,8 +79,10 @@ class ErrorDescriptor(ErrorFields, JSONEditingDescriptor):
|
||||
@classmethod
|
||||
def _construct(cls, system, contents, error_msg, location):
|
||||
|
||||
if location.name is None:
|
||||
location = location._replace(
|
||||
if isinstance(location, dict) and 'course' in location:
|
||||
location = Location(location)
|
||||
if isinstance(location, Location) and location.name is None:
|
||||
location = location.replace(
|
||||
category='error',
|
||||
# Pick a unique url_name -- the sha1 hash of the contents.
|
||||
# NOTE: We could try to pull out the url_name of the errored descriptor,
|
||||
@@ -94,8 +96,9 @@ class ErrorDescriptor(ErrorFields, JSONEditingDescriptor):
|
||||
model_data = {
|
||||
'error_msg': str(error_msg),
|
||||
'contents': contents,
|
||||
'display_name': 'Error: ' + location.name,
|
||||
'display_name': 'Error: ' + location.url(),
|
||||
'location': location,
|
||||
'category': 'error'
|
||||
}
|
||||
return cls(
|
||||
system,
|
||||
@@ -109,12 +112,12 @@ class ErrorDescriptor(ErrorFields, JSONEditingDescriptor):
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json_data, system, error_msg='Error not available'):
|
||||
def from_json(cls, json_data, system, location, error_msg='Error not available'):
|
||||
return cls._construct(
|
||||
system,
|
||||
json.dumps(json_data, indent=4),
|
||||
json.dumps(json_data, skipkeys=False, indent=4),
|
||||
error_msg,
|
||||
location=Location(json_data['location']),
|
||||
location=location
|
||||
)
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -58,8 +58,7 @@ class Date(ModelType):
|
||||
else:
|
||||
msg = "Field {0} has bad value '{1}'".format(
|
||||
self._name, field)
|
||||
log.warning(msg)
|
||||
return None
|
||||
raise TypeError(msg)
|
||||
|
||||
def to_json(self, value):
|
||||
"""
|
||||
@@ -76,9 +75,12 @@ class Date(ModelType):
|
||||
return value.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
else:
|
||||
return value.isoformat()
|
||||
else:
|
||||
raise TypeError("Cannot convert {} to json".format(value))
|
||||
|
||||
TIMEDELTA_REGEX = re.compile(r'^((?P<days>\d+?) day(?:s?))?(\s)?((?P<hours>\d+?) hour(?:s?))?(\s)?((?P<minutes>\d+?) minute(?:s)?)?(\s)?((?P<seconds>\d+?) second(?:s)?)?$')
|
||||
|
||||
|
||||
class Timedelta(ModelType):
|
||||
def from_json(self, time_str):
|
||||
"""
|
||||
|
||||
@@ -91,15 +91,18 @@ class FolditModule(FolditFields, XModule):
|
||||
PuzzleComplete.completed_puzzles(self.system.anonymous_student_id),
|
||||
key=lambda d: (d['set'], d['subset']))
|
||||
|
||||
def puzzle_leaders(self, n=10):
|
||||
def puzzle_leaders(self, n=10, courses=None):
|
||||
"""
|
||||
Returns a list of n pairs (user, score) corresponding to the top
|
||||
scores; the pairs are in descending order of score.
|
||||
"""
|
||||
from foldit.models import Score
|
||||
|
||||
leaders = [(e['username'], e['score']) for e in Score.get_tops_n(10)]
|
||||
leaders.sort(key=lambda x:-x[1])
|
||||
if courses is None:
|
||||
courses = [self.location.course_id]
|
||||
|
||||
leaders = [(leader['username'], leader['score']) for leader in Score.get_tops_n(10, course_list=courses)]
|
||||
leaders.sort(key=lambda x: -x[1])
|
||||
|
||||
return leaders
|
||||
|
||||
@@ -184,7 +187,6 @@ class FolditDescriptor(FolditFields, XmlDescriptor, EditingDescriptor):
|
||||
filename_extension = "xml"
|
||||
|
||||
has_score = True
|
||||
template_dir_name = "foldit"
|
||||
|
||||
js = {'coffee': [resource_string(__name__, 'js/src/html/edit.coffee')]}
|
||||
js_module_name = "HTMLEditingDescriptor"
|
||||
|
||||
@@ -19,10 +19,45 @@ from xblock.core import String, Scope
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_RENDER="""
|
||||
<h2>Graphic slider tool: Dynamic range and implicit functions.</h2>
|
||||
|
||||
<p>You can make the range of the x axis (but not ticks of x axis) of
|
||||
functions depend on a parameter value. This can be useful when the
|
||||
function domain needs to be variable.</p>
|
||||
<p>Implicit functions like a circle can be plotted as 2 separate
|
||||
functions of the same color.</p>
|
||||
<div style="height:50px;">
|
||||
<slider var='r' style="width:400px;float:left;"/>
|
||||
<textbox var='r' style="float:left;width:60px;margin-left:15px;"/>
|
||||
</div>
|
||||
<plot style="margin-top:15px;margin-bottom:15px;"/>
|
||||
"""
|
||||
DEFAULT_CONFIGURATION="""
|
||||
<parameters>
|
||||
<param var="r" min="5" max="25" step="0.5" initial="12.5" />
|
||||
</parameters>
|
||||
<functions>
|
||||
<function color="red">Math.sqrt(r * r - x * x)</function>
|
||||
<function color="red">-Math.sqrt(r * r - x * x)</function>
|
||||
</functions>
|
||||
<plot>
|
||||
<xrange>
|
||||
<!-- dynamic range -->
|
||||
<min>-r</min>
|
||||
<max>r</max>
|
||||
</xrange>
|
||||
<num_points>1000</num_points>
|
||||
<xticks>-30, 6, 30</xticks>
|
||||
<yticks>-30, 6, 30</yticks>
|
||||
</plot>
|
||||
"""
|
||||
|
||||
|
||||
|
||||
class GraphicalSliderToolFields(object):
|
||||
render = String(scope=Scope.content)
|
||||
configuration = String(scope=Scope.content)
|
||||
render = String(scope=Scope.content, default=DEFAULT_RENDER)
|
||||
configuration = String(scope=Scope.content, default=DEFAULT_CONFIGURATION)
|
||||
|
||||
|
||||
class GraphicalSliderToolModule(GraphicalSliderToolFields, XModule):
|
||||
@@ -141,7 +176,6 @@ class GraphicalSliderToolModule(GraphicalSliderToolFields, XModule):
|
||||
|
||||
class GraphicalSliderToolDescriptor(GraphicalSliderToolFields, MakoModuleDescriptor, XmlDescriptor):
|
||||
module_class = GraphicalSliderToolModule
|
||||
template_dir_name = 'graphical_slider_tool'
|
||||
|
||||
@classmethod
|
||||
def definition_from_xml(cls, xml_object, system):
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user