1
AUTHORS
1
AUTHORS
@@ -259,3 +259,4 @@ Afeef Janjua <janjua.afeef@gmail.com>
|
||||
Jacek Bzdak <jbzdak@gmail.com>
|
||||
Jillian Vogel <pomegranited@gmail.com>
|
||||
Dan Powell <dan@abakas.com>
|
||||
Mariana Araújo <simbelm.ne@gmail.com>
|
||||
|
||||
@@ -18,6 +18,9 @@ edX product team. You may get some valuable feedback that changes how you think
|
||||
about your idea, or you may find other developers who have the same idea and want
|
||||
to work together.
|
||||
|
||||
JIRA
|
||||
----
|
||||
|
||||
If you've got an idea for a new feature or new functionality for an existing feature,
|
||||
please start a discussion on the `edx-code`_ mailing list to get feedback from
|
||||
the community about the idea and your implementation choices.
|
||||
@@ -38,14 +41,27 @@ pull request.
|
||||
.. _start a discussion on JIRA: https://openedx.atlassian.net/secure/Dashboard.jspa
|
||||
.. _create a free JIRA account: https://openedx.atlassian.net/admin/users/sign-up
|
||||
|
||||
For real-time conversation, we use `IRC`_: we all hang out in the
|
||||
`#edx-code channel on Freenode`_. Come join us! The channel tends to be most
|
||||
active Monday through Friday between 13:00 and 21:00 UTC
|
||||
(9am to 5pm US Eastern time), but interesting conversations can happen
|
||||
at any time.
|
||||
Slack
|
||||
-----
|
||||
|
||||
.. _IRC: http://www.irchelp.org/
|
||||
.. _#edx-code channel on Freenode: http://webchat.freenode.net/?channels=edx-code
|
||||
To talk with others in the Open edX community, join us on `Slack`_.
|
||||
`Sign up for a free account`_ and join the conversation!
|
||||
The group tends to be most active Monday through Friday
|
||||
between 13:00 and 21:00 UTC (9am to 5pm US Eastern time),
|
||||
but interesting conversations can happen at any time.
|
||||
There are many different channels available for different topics, including:
|
||||
|
||||
* ``#ops`` for installation help
|
||||
* ``#events`` for upcoming events related to Open edX
|
||||
* ``#content`` for discussions about course content and creating the best courses
|
||||
|
||||
And lots more! You can also make your own channels to discuss new topics.
|
||||
|
||||
.. _Slack: https://slack.com/
|
||||
.. _Sign up for a free account: https://openedx-slack-invite.herokuapp.com/
|
||||
|
||||
Mailing Lists
|
||||
-------------
|
||||
|
||||
For asynchronous conversation, we have several mailing lists on Google Groups:
|
||||
|
||||
|
||||
@@ -10,10 +10,10 @@ from django.conf import settings
|
||||
from django.utils.translation import ugettext_lazy, ugettext as _
|
||||
from django.core.urlresolvers import resolve
|
||||
|
||||
from contentstore.utils import course_image_url
|
||||
from contentstore.course_group_config import GroupConfiguration
|
||||
from course_modes.models import CourseMode
|
||||
from eventtracking import tracker
|
||||
from openedx.core.lib.courses import course_image_url
|
||||
from search.search_engine_base import SearchEngine
|
||||
from xmodule.annotator_mixin import html_to_text
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
@shard_1
|
||||
Feature: CMS.Course checklists
|
||||
|
||||
Scenario: A course author sees checklists defined by edX
|
||||
Given I have opened a new course in Studio
|
||||
When I select Checklists from the Tools menu
|
||||
Then I see the four default edX 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 I reload the page
|
||||
Then the tasks are correctly selected
|
||||
|
||||
# There are issues getting link to be active in browsers other than chrome
|
||||
@skip_firefox
|
||||
@skip_internetexplorer
|
||||
@skip_safari
|
||||
Scenario: A task can link to a location within Studio
|
||||
Given I have opened Checklists
|
||||
When I select a link to the course outline
|
||||
Then I am brought to the course outline page
|
||||
And I press the browser back button
|
||||
Then I am brought back to the course outline in the correct state
|
||||
|
||||
# There are issues getting link to be active in browsers other than chrome
|
||||
@skip_firefox
|
||||
@skip_internetexplorer
|
||||
@skip_safari
|
||||
Scenario: A task can link to a location outside Studio
|
||||
Given I have opened Checklists
|
||||
When I select a link to help page
|
||||
Then I am brought to the help page in a new window
|
||||
@@ -1,125 +0,0 @@
|
||||
# pylint: disable=missing-docstring
|
||||
# pylint: disable=redefined-outer-name
|
||||
|
||||
from lettuce import world, step
|
||||
from nose.tools import assert_true, assert_equal
|
||||
from selenium.common.exceptions import StaleElementReferenceException
|
||||
|
||||
|
||||
############### ACTIONS ####################
|
||||
@step('I select Checklists from the Tools menu$')
|
||||
def i_select_checklists(step):
|
||||
world.click_tools()
|
||||
link_css = 'li.nav-course-tools-checklists a'
|
||||
world.css_click(link_css)
|
||||
world.wait_for_ajax_complete()
|
||||
|
||||
|
||||
@step('I have opened Checklists$')
|
||||
def i_have_opened_checklists(step):
|
||||
step.given('I have opened a new course in Studio')
|
||||
step.given('I select Checklists from the Tools menu')
|
||||
|
||||
|
||||
@step('I see the four default edX checklists$')
|
||||
def i_see_default_checklists(step):
|
||||
checklists = world.css_find('.checklist-title')
|
||||
assert_equal(4, len(checklists))
|
||||
assert_true(checklists[0].text.endswith('Getting Started With Studio'))
|
||||
assert_true(checklists[1].text.endswith('Draft a Rough Course Outline'))
|
||||
assert_true(checklists[2].text.endswith("Explore edX\'s Support Tools"))
|
||||
assert_true(checklists[3].text.endswith('Draft Your Course About Page'))
|
||||
|
||||
|
||||
@step('I can check and uncheck tasks in a checklist$')
|
||||
def i_can_check_and_uncheck_tasks(step):
|
||||
# Use the 2nd checklist as a reference
|
||||
verifyChecklist2Status(0, 7, 0)
|
||||
toggleTask(1, 0)
|
||||
verifyChecklist2Status(1, 7, 14)
|
||||
toggleTask(1, 3)
|
||||
verifyChecklist2Status(2, 7, 29)
|
||||
toggleTask(1, 6)
|
||||
verifyChecklist2Status(3, 7, 43)
|
||||
toggleTask(1, 3)
|
||||
verifyChecklist2Status(2, 7, 29)
|
||||
|
||||
|
||||
@step('the tasks are correctly selected$')
|
||||
def tasks_correctly_selected(step):
|
||||
verifyChecklist2Status(2, 7, 29)
|
||||
# verify that task 7 is still selected by toggling its checkbox state and making sure that it deselects
|
||||
world.browser.execute_script("window.scrollBy(0,1000)")
|
||||
toggleTask(1, 6)
|
||||
verifyChecklist2Status(1, 7, 14)
|
||||
|
||||
|
||||
@step('I select a link to the course outline$')
|
||||
def i_select_a_link_to_the_course_outline(step):
|
||||
clickActionLink(1, 0, 'Edit Course Outline')
|
||||
|
||||
|
||||
@step('I am brought to the course outline page$')
|
||||
def i_am_brought_to_course_outline(step):
|
||||
assert world.is_css_present('body.view-outline')
|
||||
assert_equal(1, len(world.browser.windows))
|
||||
|
||||
|
||||
@step('I am brought back to the course outline in the correct state$')
|
||||
def i_am_brought_back_to_course_outline(step):
|
||||
step.given('I see the four default edX checklists')
|
||||
# In a previous step, we selected (1, 0) in order to click the 'Edit Course Outline' link.
|
||||
# Make sure the task is still showing as selected (there was a caching bug with the collection).
|
||||
verifyChecklist2Status(1, 7, 14)
|
||||
|
||||
|
||||
@step('I select a link to help page$')
|
||||
def i_select_a_link_to_the_help_page(step):
|
||||
clickActionLink(2, 0, 'Visit Studio Help')
|
||||
|
||||
|
||||
@step('I am brought to the help page in a new window$')
|
||||
def i_am_brought_to_help_page_in_new_window(step):
|
||||
step.given('I see the four default edX checklists')
|
||||
windows = world.browser.windows
|
||||
assert_equal(2, len(windows))
|
||||
world.browser.switch_to_window(windows[1])
|
||||
assert_equal('http://help.edge.edx.org/', world.browser.url)
|
||||
|
||||
|
||||
############### HELPER METHODS ####################
|
||||
def verifyChecklist2Status(completed, total, percentage):
|
||||
def verify_count(driver):
|
||||
try:
|
||||
statusCount = world.css_find('#course-checklist1 .status-count').first
|
||||
return statusCount.text == str(completed)
|
||||
except StaleElementReferenceException:
|
||||
return False
|
||||
|
||||
world.wait_for(verify_count)
|
||||
assert_equal(str(total), world.css_find('#course-checklist1 .status-amount').first.text)
|
||||
# Would like to check the CSS width, but not sure how to do that.
|
||||
assert_equal(str(percentage), world.css_find('#course-checklist1 .viz-checklist-status-value .int').first.text)
|
||||
|
||||
|
||||
def toggleTask(checklist, task):
|
||||
world.css_click('#course-checklist' + str(checklist) + '-task' + str(task))
|
||||
world.wait_for_ajax_complete()
|
||||
|
||||
|
||||
# TODO: figure out a way to do this in phantom and firefox
|
||||
# For now we will mark the scenerios that use this method as skipped
|
||||
def clickActionLink(checklist, task, actionText):
|
||||
# text will be empty initially, wait for it to populate
|
||||
def verify_action_link_text(driver):
|
||||
actualText = world.css_text('#course-checklist' + str(checklist) + ' a', index=task)
|
||||
if actualText == actionText:
|
||||
return True
|
||||
else:
|
||||
# toggle checklist item to make sure that the link button is showing
|
||||
toggleTask(checklist, task)
|
||||
return False
|
||||
|
||||
world.wait_for(verify_action_link_text)
|
||||
world.css_click('#course-checklist' + str(checklist) + ' a', index=task)
|
||||
world.wait_for_ajax_complete()
|
||||
@@ -24,32 +24,19 @@ class Command(BaseCommand):
|
||||
"""
|
||||
help = '''Delete a MongoDB backed course'''
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if len(args) == 0:
|
||||
raise CommandError("Arguments missing: 'org/number/run commit'")
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('course_key', help="ID of the course to delete.")
|
||||
|
||||
if len(args) == 1:
|
||||
if args[0] == 'commit':
|
||||
raise CommandError("Delete_course requires a course_key <org/number/run> argument.")
|
||||
else:
|
||||
raise CommandError("Delete_course requires a commit argument at the end")
|
||||
elif len(args) == 2:
|
||||
try:
|
||||
course_key = CourseKey.from_string(args[0])
|
||||
except InvalidKeyError:
|
||||
try:
|
||||
course_key = SlashSeparatedCourseKey.from_deprecated_string(args[0])
|
||||
except InvalidKeyError:
|
||||
raise CommandError("Invalid course_key: '%s'. Proper syntax: 'org/number/run commit' " % args[0])
|
||||
if args[1] != 'commit':
|
||||
raise CommandError("Delete_course requires a commit argument at the end")
|
||||
elif len(args) > 2:
|
||||
raise CommandError("Too many arguments! Expected <course_key> <commit>")
|
||||
def handle(self, *args, **options):
|
||||
try:
|
||||
course_key = CourseKey.from_string(options['course_key'])
|
||||
except InvalidKeyError:
|
||||
raise CommandError("Invalid course_key: '%s'." % options['course_key'])
|
||||
|
||||
if not modulestore().get_course(course_key):
|
||||
raise CommandError("Course with '%s' key not found." % args[0])
|
||||
raise CommandError("Course with '%s' key not found." % options['course_key'])
|
||||
|
||||
print 'Actually going to delete the %s course from DB....' % args[0]
|
||||
print 'Going to delete the %s course from DB....' % options['course_key']
|
||||
if query_yes_no("Deleting course {0}. Confirm?".format(course_key), default="no"):
|
||||
if query_yes_no("Are you sure. This action cannot be undone!", default="no"):
|
||||
delete_course_and_groups(course_key, ModuleStoreEnum.UserID.mgmt_command)
|
||||
|
||||
@@ -6,72 +6,12 @@ import unittest
|
||||
import mock
|
||||
|
||||
from opaque_keys.edx.locations import SlashSeparatedCourseKey
|
||||
from django.core.management import CommandError
|
||||
from contentstore.management.commands.delete_course import Command
|
||||
from django.core.management import call_command, CommandError
|
||||
from contentstore.tests.utils import CourseTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
|
||||
class TestArgParsing(unittest.TestCase):
|
||||
"""
|
||||
Tests for parsing arguments for the 'delete_course' management command
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(TestArgParsing, self).setUp()
|
||||
|
||||
self.command = Command()
|
||||
|
||||
def test_no_args(self):
|
||||
"""
|
||||
Testing 'delete_course' command with no arguments provided
|
||||
"""
|
||||
errstring = "Arguments missing: 'org/number/run commit'"
|
||||
with self.assertRaisesRegexp(CommandError, errstring):
|
||||
self.command.handle()
|
||||
|
||||
def test_no_course_key(self):
|
||||
"""
|
||||
Testing 'delete_course' command with no course key provided
|
||||
"""
|
||||
errstring = "Delete_course requires a course_key <org/number/run> argument."
|
||||
with self.assertRaisesRegexp(CommandError, errstring):
|
||||
self.command.handle("commit")
|
||||
|
||||
def test_commit_argument(self):
|
||||
"""
|
||||
Testing 'delete_course' command without 'commit' argument
|
||||
"""
|
||||
errstring = "Delete_course requires a commit argument at the end"
|
||||
with self.assertRaisesRegexp(CommandError, errstring):
|
||||
self.command.handle("TestX/TS01/run")
|
||||
|
||||
def test_invalid_course_key(self):
|
||||
"""
|
||||
Testing 'delete_course' command with an invalid course key argument
|
||||
"""
|
||||
errstring = "Invalid course_key: 'TestX/TS01'. Proper syntax: 'org/number/run commit' "
|
||||
with self.assertRaisesRegexp(CommandError, errstring):
|
||||
self.command.handle("TestX/TS01", "commit")
|
||||
|
||||
def test_missing_commit_argument(self):
|
||||
"""
|
||||
Testing 'delete_course' command with misspelled 'commit' argument
|
||||
"""
|
||||
errstring = "Delete_course requires a commit argument at the end"
|
||||
with self.assertRaisesRegexp(CommandError, errstring):
|
||||
self.command.handle("TestX/TS01/run", "comit")
|
||||
|
||||
def test_too_many_arguments(self):
|
||||
"""
|
||||
Testing 'delete_course' command with more than 2 arguments
|
||||
"""
|
||||
errstring = "Too many arguments! Expected <course_key> <commit>"
|
||||
with self.assertRaisesRegexp(CommandError, errstring):
|
||||
self.command.handle("TestX/TS01/run", "commit", "invalid")
|
||||
|
||||
|
||||
class DeleteCourseTest(CourseTestCase):
|
||||
"""
|
||||
Test for course deleting functionality of the 'delete_course' command
|
||||
@@ -82,8 +22,6 @@ class DeleteCourseTest(CourseTestCase):
|
||||
def setUp(self):
|
||||
super(DeleteCourseTest, self).setUp()
|
||||
|
||||
self.command = Command()
|
||||
|
||||
org = 'TestX'
|
||||
course_number = 'TS01'
|
||||
course_run = '2015_Q1'
|
||||
@@ -95,13 +33,21 @@ class DeleteCourseTest(CourseTestCase):
|
||||
run=course_run
|
||||
)
|
||||
|
||||
def test_invalid_key_not_found(self):
|
||||
"""
|
||||
Test for when a course key is malformed
|
||||
"""
|
||||
errstring = "Invalid course_key: 'foo/TestX/TS01/2015_Q7'."
|
||||
with self.assertRaisesRegexp(CommandError, errstring):
|
||||
call_command('delete_course', 'foo/TestX/TS01/2015_Q7')
|
||||
|
||||
def test_course_key_not_found(self):
|
||||
"""
|
||||
Test for when a non-existing course key is entered
|
||||
"""
|
||||
errstring = "Course with 'TestX/TS01/2015_Q7' key not found."
|
||||
with self.assertRaisesRegexp(CommandError, errstring):
|
||||
self.command.handle('TestX/TS01/2015_Q7', "commit")
|
||||
call_command('delete_course', 'TestX/TS01/2015_Q7')
|
||||
|
||||
def test_course_deleted(self):
|
||||
"""
|
||||
@@ -113,5 +59,5 @@ class DeleteCourseTest(CourseTestCase):
|
||||
|
||||
with mock.patch(self.YESNO_PATCH_LOCATION) as patched_yes_no:
|
||||
patched_yes_no.return_value = True
|
||||
self.command.handle('TestX/TS01/2015_Q1', "commit")
|
||||
call_command('delete_course', 'TestX/TS01/2015_Q1')
|
||||
self.assertIsNone(modulestore().get_course(SlashSeparatedCourseKey("TestX", "TS01", "2015_Q1")))
|
||||
|
||||
@@ -698,7 +698,7 @@ class MiscCourseTests(ContentStoreTestCase):
|
||||
self.check_components_on_page(
|
||||
ADVANCED_COMPONENT_TYPES,
|
||||
['Word cloud', 'Annotation', 'Text Annotation', 'Video Annotation', 'Image Annotation',
|
||||
'Open Response Assessment', 'Peer Grading Interface', 'split_test'],
|
||||
'split_test'],
|
||||
)
|
||||
|
||||
@ddt.data('/Fake/asset/displayname', '\\Fake\\asset\\displayname')
|
||||
@@ -1510,7 +1510,6 @@ class ContentStoreTest(ContentStoreTestCase, XssTestMixin):
|
||||
test_get_html('export_handler')
|
||||
test_get_html('course_team_handler')
|
||||
test_get_html('course_info_handler')
|
||||
test_get_html('checklists_handler')
|
||||
test_get_html('assets_handler')
|
||||
test_get_html('tabs_handler')
|
||||
test_get_html('settings_handler')
|
||||
@@ -1694,7 +1693,6 @@ class ContentStoreTest(ContentStoreTestCase, XssTestMixin):
|
||||
self.assertEqual(course.textbooks, [])
|
||||
self.assertIn('GRADER', course.grading_policy)
|
||||
self.assertIn('GRADE_CUTOFFS', course.grading_policy)
|
||||
self.assertGreaterEqual(len(course.checklists), 4)
|
||||
|
||||
# by fetching
|
||||
fetched_course = self.store.get_item(course.location)
|
||||
@@ -1703,8 +1701,6 @@ class ContentStoreTest(ContentStoreTestCase, XssTestMixin):
|
||||
self.assertEqual(course.start, fetched_course.start)
|
||||
self.assertEqual(fetched_course.start, fetched_item.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)
|
||||
|
||||
def test_image_import(self):
|
||||
"""Test backwards compatibilty of course image."""
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
Test view handler for rerun (and eventually create)
|
||||
"""
|
||||
import ddt
|
||||
from mock import patch
|
||||
|
||||
from django.test.client import RequestFactory
|
||||
from django.core.urlresolvers import reverse
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
@@ -15,6 +17,10 @@ from student.tests.factories import UserFactory
|
||||
from contentstore.tests.utils import AjaxEnabledTestClient, parse_json
|
||||
from datetime import datetime
|
||||
from xmodule.course_module import CourseFields
|
||||
from util.organizations_helpers import (
|
||||
add_organization,
|
||||
get_course_organizations,
|
||||
)
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
@@ -33,7 +39,7 @@ class TestCourseListing(ModuleStoreTestCase):
|
||||
self.factory = RequestFactory()
|
||||
self.client = AjaxEnabledTestClient()
|
||||
self.client.login(username=self.user.username, password='test')
|
||||
|
||||
self.course_create_rerun_url = reverse('course_handler')
|
||||
source_course = CourseFactory.create(
|
||||
org='origin',
|
||||
number='the_beginning',
|
||||
@@ -57,7 +63,7 @@ class TestCourseListing(ModuleStoreTestCase):
|
||||
"""
|
||||
Just testing the functionality the view handler adds over the tasks tested in test_clone_course
|
||||
"""
|
||||
response = self.client.ajax_post('/course/', {
|
||||
response = self.client.ajax_post(self.course_create_rerun_url, {
|
||||
'source_course_key': unicode(self.source_course_key),
|
||||
'org': self.source_course_key.org, 'course': self.source_course_key.course, 'run': 'copy',
|
||||
'display_name': 'not the same old name',
|
||||
@@ -76,7 +82,7 @@ class TestCourseListing(ModuleStoreTestCase):
|
||||
Tests newly created course has web certs enabled by default.
|
||||
"""
|
||||
with modulestore().default_store(store):
|
||||
response = self.client.ajax_post('/course/', {
|
||||
response = self.client.ajax_post(self.course_create_rerun_url, {
|
||||
'org': 'orgX',
|
||||
'number': 'CS101',
|
||||
'display_name': 'Course with web certs enabled',
|
||||
@@ -87,3 +93,66 @@ class TestCourseListing(ModuleStoreTestCase):
|
||||
new_course_key = CourseKey.from_string(data['course_key'])
|
||||
course = self.store.get_course(new_course_key)
|
||||
self.assertTrue(course.cert_html_view_enabled)
|
||||
|
||||
@patch.dict('django.conf.settings.FEATURES', {'ORGANIZATIONS_APP': False})
|
||||
@ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split)
|
||||
def test_course_creation_without_org_app_enabled(self, store):
|
||||
"""
|
||||
Tests course creation workflow should not create course to org
|
||||
link if organizations_app is not enabled.
|
||||
"""
|
||||
with modulestore().default_store(store):
|
||||
response = self.client.ajax_post(self.course_create_rerun_url, {
|
||||
'org': 'orgX',
|
||||
'number': 'CS101',
|
||||
'display_name': 'Course with web certs enabled',
|
||||
'run': '2015_T2'
|
||||
})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = parse_json(response)
|
||||
new_course_key = CourseKey.from_string(data['course_key'])
|
||||
course_orgs = get_course_organizations(new_course_key)
|
||||
self.assertEqual(course_orgs, [])
|
||||
|
||||
@patch.dict('django.conf.settings.FEATURES', {'ORGANIZATIONS_APP': True})
|
||||
@ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split)
|
||||
def test_course_creation_with_org_not_in_system(self, store):
|
||||
"""
|
||||
Tests course creation workflow when course organization does not exist
|
||||
in system.
|
||||
"""
|
||||
with modulestore().default_store(store):
|
||||
response = self.client.ajax_post(self.course_create_rerun_url, {
|
||||
'org': 'orgX',
|
||||
'number': 'CS101',
|
||||
'display_name': 'Course with web certs enabled',
|
||||
'run': '2015_T2'
|
||||
})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
data = parse_json(response)
|
||||
self.assertIn(u'Organization you selected does not exist in the system', data['error'])
|
||||
|
||||
@patch.dict('django.conf.settings.FEATURES', {'ORGANIZATIONS_APP': True})
|
||||
@ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split)
|
||||
def test_course_creation_with_org_in_system(self, store):
|
||||
"""
|
||||
Tests course creation workflow when course organization exist in system.
|
||||
"""
|
||||
add_organization({
|
||||
'name': 'Test Organization',
|
||||
'short_name': 'orgX',
|
||||
'description': 'Testing Organization Description',
|
||||
})
|
||||
with modulestore().default_store(store):
|
||||
response = self.client.ajax_post(self.course_create_rerun_url, {
|
||||
'org': 'orgX',
|
||||
'number': 'CS101',
|
||||
'display_name': 'Course with web certs enabled',
|
||||
'run': '2015_T2'
|
||||
})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = parse_json(response)
|
||||
new_course_key = CourseKey.from_string(data['course_key'])
|
||||
course_orgs = get_course_organizations(new_course_key)
|
||||
self.assertEqual(len(course_orgs), 1)
|
||||
self.assertEqual(course_orgs[0]['short_name'], 'orgX')
|
||||
|
||||
@@ -2,63 +2,44 @@
|
||||
Tests for Studio Course Settings.
|
||||
"""
|
||||
import datetime
|
||||
import ddt
|
||||
import json
|
||||
import copy
|
||||
import mock
|
||||
from mock import patch
|
||||
import unittest
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.timezone import UTC
|
||||
from django.test.utils import override_settings
|
||||
from django.conf import settings
|
||||
|
||||
from models.settings.course_details import (CourseDetails, CourseSettingsEncoder)
|
||||
from models.settings.course_grading import CourseGradingModel
|
||||
from contentstore.utils import reverse_course_url, reverse_usage_url
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from contentstore.views.component import ADVANCED_COMPONENT_POLICY_KEY
|
||||
from models.settings.course_grading import CourseGradingModel
|
||||
from models.settings.course_metadata import CourseMetadata
|
||||
from models.settings.encoder import CourseSettingsEncoder
|
||||
from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
|
||||
from openedx.core.djangoapps.models.course_details import CourseDetails
|
||||
from student.roles import CourseInstructorRole
|
||||
from student.tests.factories import UserFactory
|
||||
|
||||
|
||||
from models.settings.course_metadata import CourseMetadata
|
||||
from xmodule.fields import Date
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from xmodule.tabs import InvalidTabsException
|
||||
from util.milestones_helpers import seed_milestone_relationship_types
|
||||
|
||||
from .utils import CourseTestCase
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from contentstore.views.component import ADVANCED_COMPONENT_POLICY_KEY
|
||||
import ddt
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
|
||||
from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
|
||||
from util.milestones_helpers import seed_milestone_relationship_types
|
||||
|
||||
|
||||
def get_url(course_id, handler_name='settings_handler'):
|
||||
return reverse_course_url(handler_name, course_id)
|
||||
|
||||
|
||||
class CourseDetailsTestCase(CourseTestCase):
|
||||
class CourseSettingsEncoderTest(CourseTestCase):
|
||||
"""
|
||||
Tests the first course settings page (course dates, overview, etc.).
|
||||
Tests for CourseSettingsEncoder.
|
||||
"""
|
||||
def test_virgin_fetch(self):
|
||||
details = CourseDetails.fetch(self.course.id)
|
||||
self.assertEqual(details.org, self.course.location.org, "Org not copied into")
|
||||
self.assertEqual(details.course_id, self.course.location.course, "Course_id not copied into")
|
||||
self.assertEqual(details.run, self.course.location.name, "Course name not copied into")
|
||||
self.assertEqual(details.course_image_name, self.course.course_image)
|
||||
self.assertIsNotNone(details.start_date.tzinfo)
|
||||
self.assertIsNone(details.end_date, "end date somehow initialized " + str(details.end_date))
|
||||
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.assertIsNone(details.intro_video, "intro_video somehow initialized" + str(details.intro_video))
|
||||
self.assertIsNone(details.effort, "effort somehow initialized" + str(details.effort))
|
||||
self.assertIsNone(details.language, "language somehow initialized" + str(details.language))
|
||||
self.assertIsNone(details.has_cert_config)
|
||||
self.assertFalse(details.self_paced)
|
||||
|
||||
def test_encoder(self):
|
||||
details = CourseDetails.fetch(self.course.id)
|
||||
jsondetails = json.dumps(details, cls=CourseSettingsEncoder)
|
||||
@@ -87,60 +68,163 @@ class CourseDetailsTestCase(CourseTestCase):
|
||||
self.assertEquals(1, jsondetails['number'])
|
||||
self.assertEqual(jsondetails['string'], 'string')
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class CourseDetailsViewTest(CourseTestCase):
|
||||
"""
|
||||
Tests for modifying content on the first course settings page (course dates, overview, etc.).
|
||||
"""
|
||||
def setUp(self):
|
||||
super(CourseDetailsViewTest, self).setUp()
|
||||
|
||||
def alter_field(self, url, details, field, val):
|
||||
"""
|
||||
Change the one field to the given value and then invoke the update post to see if it worked.
|
||||
"""
|
||||
setattr(details, field, val)
|
||||
# Need to partially serialize payload b/c the mock doesn't handle it correctly
|
||||
payload = copy.copy(details.__dict__)
|
||||
payload['start_date'] = CourseDetailsViewTest.convert_datetime_to_iso(details.start_date)
|
||||
payload['end_date'] = CourseDetailsViewTest.convert_datetime_to_iso(details.end_date)
|
||||
payload['enrollment_start'] = CourseDetailsViewTest.convert_datetime_to_iso(details.enrollment_start)
|
||||
payload['enrollment_end'] = CourseDetailsViewTest.convert_datetime_to_iso(details.enrollment_end)
|
||||
resp = self.client.ajax_post(url, payload)
|
||||
self.compare_details_with_encoding(json.loads(resp.content), details.__dict__, field + str(val))
|
||||
|
||||
@staticmethod
|
||||
def convert_datetime_to_iso(datetime_obj):
|
||||
"""
|
||||
Use the xblock serializer to convert the datetime
|
||||
"""
|
||||
return Date().to_json(datetime_obj)
|
||||
|
||||
def test_update_and_fetch(self):
|
||||
SelfPacedConfiguration(enabled=True).save()
|
||||
jsondetails = CourseDetails.fetch(self.course.id)
|
||||
jsondetails.syllabus = "<a href='foo'>bar</a>"
|
||||
# encode - decode to convert date fields and other data which changes form
|
||||
details = CourseDetails.fetch(self.course.id)
|
||||
|
||||
# resp s/b json from here on
|
||||
url = get_url(self.course.id)
|
||||
resp = self.client.get_json(url)
|
||||
self.compare_details_with_encoding(json.loads(resp.content), details.__dict__, "virgin get")
|
||||
|
||||
utc = UTC()
|
||||
self.alter_field(url, details, 'start_date', datetime.datetime(2012, 11, 12, 1, 30, tzinfo=utc))
|
||||
self.alter_field(url, details, 'start_date', datetime.datetime(2012, 11, 1, 13, 30, tzinfo=utc))
|
||||
self.alter_field(url, details, 'end_date', datetime.datetime(2013, 2, 12, 1, 30, tzinfo=utc))
|
||||
self.alter_field(url, details, 'enrollment_start', datetime.datetime(2012, 10, 12, 1, 30, tzinfo=utc))
|
||||
|
||||
self.alter_field(url, details, 'enrollment_end', datetime.datetime(2012, 11, 15, 1, 30, tzinfo=utc))
|
||||
self.alter_field(url, details, 'short_description', "Short Description")
|
||||
self.alter_field(url, details, 'overview', "Overview")
|
||||
self.alter_field(url, details, 'intro_video', "intro_video")
|
||||
self.alter_field(url, details, 'effort', "effort")
|
||||
self.alter_field(url, details, 'course_image_name', "course_image_name")
|
||||
self.alter_field(url, details, 'language', "en")
|
||||
self.alter_field(url, details, 'self_paced', "true")
|
||||
|
||||
def compare_details_with_encoding(self, encoded, details, context):
|
||||
"""
|
||||
compare all of the fields of the before and after dicts
|
||||
"""
|
||||
self.compare_date_fields(details, encoded, context, 'start_date')
|
||||
self.compare_date_fields(details, encoded, context, 'end_date')
|
||||
self.compare_date_fields(details, encoded, context, 'enrollment_start')
|
||||
self.compare_date_fields(details, encoded, context, 'enrollment_end')
|
||||
self.assertEqual(
|
||||
CourseDetails.update_from_json(self.course.id, jsondetails.__dict__, self.user).syllabus,
|
||||
jsondetails.syllabus, "After set syllabus"
|
||||
)
|
||||
jsondetails.short_description = "Short Description"
|
||||
self.assertEqual(
|
||||
CourseDetails.update_from_json(self.course.id, jsondetails.__dict__, self.user).short_description,
|
||||
jsondetails.short_description, "After set short_description"
|
||||
)
|
||||
jsondetails.overview = "Overview"
|
||||
self.assertEqual(
|
||||
CourseDetails.update_from_json(self.course.id, jsondetails.__dict__, self.user).overview,
|
||||
jsondetails.overview, "After set overview"
|
||||
)
|
||||
jsondetails.intro_video = "intro_video"
|
||||
self.assertEqual(
|
||||
CourseDetails.update_from_json(self.course.id, jsondetails.__dict__, self.user).intro_video,
|
||||
jsondetails.intro_video, "After set intro_video"
|
||||
)
|
||||
jsondetails.effort = "effort"
|
||||
self.assertEqual(
|
||||
CourseDetails.update_from_json(self.course.id, jsondetails.__dict__, self.user).effort,
|
||||
jsondetails.effort, "After set effort"
|
||||
)
|
||||
jsondetails.self_paced = True
|
||||
self.assertEqual(
|
||||
CourseDetails.update_from_json(self.course.id, jsondetails.__dict__, self.user).self_paced,
|
||||
jsondetails.self_paced
|
||||
)
|
||||
jsondetails.start_date = datetime.datetime(2010, 10, 1, 0, tzinfo=UTC())
|
||||
self.assertEqual(
|
||||
CourseDetails.update_from_json(self.course.id, jsondetails.__dict__, self.user).start_date,
|
||||
jsondetails.start_date
|
||||
)
|
||||
jsondetails.end_date = datetime.datetime(2011, 10, 1, 0, tzinfo=UTC())
|
||||
self.assertEqual(
|
||||
CourseDetails.update_from_json(self.course.id, jsondetails.__dict__, self.user).end_date,
|
||||
jsondetails.end_date
|
||||
)
|
||||
jsondetails.course_image_name = "an_image.jpg"
|
||||
self.assertEqual(
|
||||
CourseDetails.update_from_json(self.course.id, jsondetails.__dict__, self.user).course_image_name,
|
||||
jsondetails.course_image_name
|
||||
)
|
||||
jsondetails.language = "hr"
|
||||
self.assertEqual(
|
||||
CourseDetails.update_from_json(self.course.id, jsondetails.__dict__, self.user).language,
|
||||
jsondetails.language
|
||||
details['short_description'], encoded['short_description'], context + " short_description not =="
|
||||
)
|
||||
self.assertEqual(details['overview'], encoded['overview'], context + " overviews not ==")
|
||||
self.assertEqual(details['intro_video'], encoded.get('intro_video', None), context + " intro_video not ==")
|
||||
self.assertEqual(details['effort'], encoded['effort'], context + " efforts not ==")
|
||||
self.assertEqual(details['course_image_name'], encoded['course_image_name'], context + " images not ==")
|
||||
self.assertEqual(details['language'], encoded['language'], context + " languages not ==")
|
||||
|
||||
def compare_date_fields(self, details, encoded, context, field):
|
||||
"""
|
||||
Compare the given date fields between the before and after doing json deserialization
|
||||
"""
|
||||
if details[field] is not None:
|
||||
date = Date()
|
||||
if field in encoded and encoded[field] is not None:
|
||||
dt1 = date.from_json(encoded[field])
|
||||
dt2 = details[field]
|
||||
|
||||
self.assertEqual(dt1, dt2, msg="{} != {} at {}".format(dt1, dt2, context))
|
||||
else:
|
||||
self.fail(field + " missing from encoded but in details at " + context)
|
||||
elif field in encoded and encoded[field] is not None:
|
||||
self.fail(field + " included in encoding but missing from details at " + context)
|
||||
|
||||
@mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_PREREQUISITE_COURSES': True, 'MILESTONES_APP': True})
|
||||
def test_pre_requisite_course_list_present(self):
|
||||
seed_milestone_relationship_types()
|
||||
settings_details_url = get_url(self.course.id)
|
||||
response = self.client.get_html(settings_details_url)
|
||||
self.assertContains(response, "Prerequisite Course")
|
||||
|
||||
@mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_PREREQUISITE_COURSES': True, 'MILESTONES_APP': True})
|
||||
def test_pre_requisite_course_update_and_fetch(self):
|
||||
seed_milestone_relationship_types()
|
||||
url = get_url(self.course.id)
|
||||
resp = self.client.get_json(url)
|
||||
course_detail_json = json.loads(resp.content)
|
||||
# assert pre_requisite_courses is initialized
|
||||
self.assertEqual([], course_detail_json['pre_requisite_courses'])
|
||||
|
||||
# update pre requisite courses with a new course keys
|
||||
pre_requisite_course = CourseFactory.create(org='edX', course='900', run='test_run')
|
||||
pre_requisite_course2 = CourseFactory.create(org='edX', course='902', run='test_run')
|
||||
pre_requisite_course_keys = [unicode(pre_requisite_course.id), unicode(pre_requisite_course2.id)]
|
||||
course_detail_json['pre_requisite_courses'] = pre_requisite_course_keys
|
||||
self.client.ajax_post(url, course_detail_json)
|
||||
|
||||
# fetch updated course to assert pre_requisite_courses has new values
|
||||
resp = self.client.get_json(url)
|
||||
course_detail_json = json.loads(resp.content)
|
||||
self.assertEqual(pre_requisite_course_keys, course_detail_json['pre_requisite_courses'])
|
||||
|
||||
# remove pre requisite course
|
||||
course_detail_json['pre_requisite_courses'] = []
|
||||
self.client.ajax_post(url, course_detail_json)
|
||||
resp = self.client.get_json(url)
|
||||
course_detail_json = json.loads(resp.content)
|
||||
self.assertEqual([], course_detail_json['pre_requisite_courses'])
|
||||
|
||||
@mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_PREREQUISITE_COURSES': True, 'MILESTONES_APP': True})
|
||||
def test_invalid_pre_requisite_course(self):
|
||||
seed_milestone_relationship_types()
|
||||
url = get_url(self.course.id)
|
||||
resp = self.client.get_json(url)
|
||||
course_detail_json = json.loads(resp.content)
|
||||
|
||||
# update pre requisite courses one valid and one invalid key
|
||||
pre_requisite_course = CourseFactory.create(org='edX', course='900', run='test_run')
|
||||
pre_requisite_course_keys = [unicode(pre_requisite_course.id), 'invalid_key']
|
||||
course_detail_json['pre_requisite_courses'] = pre_requisite_course_keys
|
||||
response = self.client.ajax_post(url, course_detail_json)
|
||||
self.assertEqual(400, response.status_code)
|
||||
|
||||
@ddt.data(
|
||||
(False, False, False),
|
||||
(True, False, True),
|
||||
(False, True, False),
|
||||
(True, True, True),
|
||||
)
|
||||
def test_visibility_of_entrance_exam_section(self, feature_flags):
|
||||
"""
|
||||
Tests entrance exam section is available if ENTRANCE_EXAMS feature is enabled no matter any other
|
||||
feature is enabled or disabled i.e ENABLE_MKTG_SITE.
|
||||
"""
|
||||
with patch.dict("django.conf.settings.FEATURES", {
|
||||
'ENTRANCE_EXAMS': feature_flags[0],
|
||||
'ENABLE_MKTG_SITE': feature_flags[1]
|
||||
}):
|
||||
course_details_url = get_url(self.course.id)
|
||||
resp = self.client.get_html(course_details_url)
|
||||
self.assertEqual(
|
||||
feature_flags[2],
|
||||
'<h3 id="heading-entrance-exam">' in resp.content
|
||||
)
|
||||
|
||||
@override_settings(MKTG_URLS={'ROOT': 'dummy-root'})
|
||||
def test_marketing_site_fetch(self):
|
||||
@@ -296,175 +380,6 @@ class CourseDetailsTestCase(CourseTestCase):
|
||||
self.assertContains(response, "Course Introduction Video")
|
||||
self.assertContains(response, "Requirements")
|
||||
|
||||
def test_toggle_pacing_during_course_run(self):
|
||||
SelfPacedConfiguration(enabled=True).save()
|
||||
self.course.start = datetime.datetime.now()
|
||||
modulestore().update_item(self.course, self.user.id)
|
||||
|
||||
details = CourseDetails.fetch(self.course.id)
|
||||
updated_details = CourseDetails.update_from_json(
|
||||
self.course.id,
|
||||
dict(details.__dict__, self_paced=True),
|
||||
self.user
|
||||
)
|
||||
self.assertFalse(updated_details.self_paced)
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class CourseDetailsViewTest(CourseTestCase):
|
||||
"""
|
||||
Tests for modifying content on the first course settings page (course dates, overview, etc.).
|
||||
"""
|
||||
def setUp(self):
|
||||
super(CourseDetailsViewTest, self).setUp()
|
||||
|
||||
def alter_field(self, url, details, field, val):
|
||||
"""
|
||||
Change the one field to the given value and then invoke the update post to see if it worked.
|
||||
"""
|
||||
setattr(details, field, val)
|
||||
# Need to partially serialize payload b/c the mock doesn't handle it correctly
|
||||
payload = copy.copy(details.__dict__)
|
||||
payload['start_date'] = CourseDetailsViewTest.convert_datetime_to_iso(details.start_date)
|
||||
payload['end_date'] = CourseDetailsViewTest.convert_datetime_to_iso(details.end_date)
|
||||
payload['enrollment_start'] = CourseDetailsViewTest.convert_datetime_to_iso(details.enrollment_start)
|
||||
payload['enrollment_end'] = CourseDetailsViewTest.convert_datetime_to_iso(details.enrollment_end)
|
||||
resp = self.client.ajax_post(url, payload)
|
||||
self.compare_details_with_encoding(json.loads(resp.content), details.__dict__, field + str(val))
|
||||
|
||||
@staticmethod
|
||||
def convert_datetime_to_iso(datetime_obj):
|
||||
"""
|
||||
Use the xblock serializer to convert the datetime
|
||||
"""
|
||||
return Date().to_json(datetime_obj)
|
||||
|
||||
def test_update_and_fetch(self):
|
||||
SelfPacedConfiguration(enabled=True).save()
|
||||
details = CourseDetails.fetch(self.course.id)
|
||||
|
||||
# resp s/b json from here on
|
||||
url = get_url(self.course.id)
|
||||
resp = self.client.get_json(url)
|
||||
self.compare_details_with_encoding(json.loads(resp.content), details.__dict__, "virgin get")
|
||||
|
||||
utc = UTC()
|
||||
self.alter_field(url, details, 'start_date', datetime.datetime(2012, 11, 12, 1, 30, tzinfo=utc))
|
||||
self.alter_field(url, details, 'start_date', datetime.datetime(2012, 11, 1, 13, 30, tzinfo=utc))
|
||||
self.alter_field(url, details, 'end_date', datetime.datetime(2013, 2, 12, 1, 30, tzinfo=utc))
|
||||
self.alter_field(url, details, 'enrollment_start', datetime.datetime(2012, 10, 12, 1, 30, tzinfo=utc))
|
||||
|
||||
self.alter_field(url, details, 'enrollment_end', datetime.datetime(2012, 11, 15, 1, 30, tzinfo=utc))
|
||||
self.alter_field(url, details, 'short_description', "Short Description")
|
||||
self.alter_field(url, details, 'overview', "Overview")
|
||||
self.alter_field(url, details, 'intro_video', "intro_video")
|
||||
self.alter_field(url, details, 'effort', "effort")
|
||||
self.alter_field(url, details, 'course_image_name', "course_image_name")
|
||||
self.alter_field(url, details, 'language', "en")
|
||||
self.alter_field(url, details, 'self_paced', "true")
|
||||
|
||||
def compare_details_with_encoding(self, encoded, details, context):
|
||||
"""
|
||||
compare all of the fields of the before and after dicts
|
||||
"""
|
||||
self.compare_date_fields(details, encoded, context, 'start_date')
|
||||
self.compare_date_fields(details, encoded, context, 'end_date')
|
||||
self.compare_date_fields(details, encoded, context, 'enrollment_start')
|
||||
self.compare_date_fields(details, encoded, context, 'enrollment_end')
|
||||
self.assertEqual(details['short_description'], encoded['short_description'], context + " short_description not ==")
|
||||
self.assertEqual(details['overview'], encoded['overview'], context + " overviews not ==")
|
||||
self.assertEqual(details['intro_video'], encoded.get('intro_video', None), context + " intro_video not ==")
|
||||
self.assertEqual(details['effort'], encoded['effort'], context + " efforts not ==")
|
||||
self.assertEqual(details['course_image_name'], encoded['course_image_name'], context + " images not ==")
|
||||
self.assertEqual(details['language'], encoded['language'], context + " languages not ==")
|
||||
|
||||
def compare_date_fields(self, details, encoded, context, field):
|
||||
"""
|
||||
Compare the given date fields between the before and after doing json deserialization
|
||||
"""
|
||||
if details[field] is not None:
|
||||
date = Date()
|
||||
if field in encoded and encoded[field] is not None:
|
||||
dt1 = date.from_json(encoded[field])
|
||||
dt2 = details[field]
|
||||
|
||||
self.assertEqual(dt1, dt2, msg="{} != {} at {}".format(dt1, dt2, context))
|
||||
else:
|
||||
self.fail(field + " missing from encoded but in details at " + context)
|
||||
elif field in encoded and encoded[field] is not None:
|
||||
self.fail(field + " included in encoding but missing from details at " + context)
|
||||
|
||||
@mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_PREREQUISITE_COURSES': True, 'MILESTONES_APP': True})
|
||||
def test_pre_requisite_course_list_present(self):
|
||||
seed_milestone_relationship_types()
|
||||
settings_details_url = get_url(self.course.id)
|
||||
response = self.client.get_html(settings_details_url)
|
||||
self.assertContains(response, "Prerequisite Course")
|
||||
|
||||
@mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_PREREQUISITE_COURSES': True, 'MILESTONES_APP': True})
|
||||
def test_pre_requisite_course_update_and_fetch(self):
|
||||
seed_milestone_relationship_types()
|
||||
url = get_url(self.course.id)
|
||||
resp = self.client.get_json(url)
|
||||
course_detail_json = json.loads(resp.content)
|
||||
# assert pre_requisite_courses is initialized
|
||||
self.assertEqual([], course_detail_json['pre_requisite_courses'])
|
||||
|
||||
# update pre requisite courses with a new course keys
|
||||
pre_requisite_course = CourseFactory.create(org='edX', course='900', run='test_run')
|
||||
pre_requisite_course2 = CourseFactory.create(org='edX', course='902', run='test_run')
|
||||
pre_requisite_course_keys = [unicode(pre_requisite_course.id), unicode(pre_requisite_course2.id)]
|
||||
course_detail_json['pre_requisite_courses'] = pre_requisite_course_keys
|
||||
self.client.ajax_post(url, course_detail_json)
|
||||
|
||||
# fetch updated course to assert pre_requisite_courses has new values
|
||||
resp = self.client.get_json(url)
|
||||
course_detail_json = json.loads(resp.content)
|
||||
self.assertEqual(pre_requisite_course_keys, course_detail_json['pre_requisite_courses'])
|
||||
|
||||
# remove pre requisite course
|
||||
course_detail_json['pre_requisite_courses'] = []
|
||||
self.client.ajax_post(url, course_detail_json)
|
||||
resp = self.client.get_json(url)
|
||||
course_detail_json = json.loads(resp.content)
|
||||
self.assertEqual([], course_detail_json['pre_requisite_courses'])
|
||||
|
||||
@mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_PREREQUISITE_COURSES': True, 'MILESTONES_APP': True})
|
||||
def test_invalid_pre_requisite_course(self):
|
||||
seed_milestone_relationship_types()
|
||||
url = get_url(self.course.id)
|
||||
resp = self.client.get_json(url)
|
||||
course_detail_json = json.loads(resp.content)
|
||||
|
||||
# update pre requisite courses one valid and one invalid key
|
||||
pre_requisite_course = CourseFactory.create(org='edX', course='900', run='test_run')
|
||||
pre_requisite_course_keys = [unicode(pre_requisite_course.id), 'invalid_key']
|
||||
course_detail_json['pre_requisite_courses'] = pre_requisite_course_keys
|
||||
response = self.client.ajax_post(url, course_detail_json)
|
||||
self.assertEqual(400, response.status_code)
|
||||
|
||||
@ddt.data(
|
||||
(False, False, False),
|
||||
(True, False, True),
|
||||
(False, True, False),
|
||||
(True, True, True),
|
||||
)
|
||||
def test_visibility_of_entrance_exam_section(self, feature_flags):
|
||||
"""
|
||||
Tests entrance exam section is available if ENTRANCE_EXAMS feature is enabled no matter any other
|
||||
feature is enabled or disabled i.e ENABLE_MKTG_SITE.
|
||||
"""
|
||||
with patch.dict("django.conf.settings.FEATURES", {
|
||||
'ENTRANCE_EXAMS': feature_flags[0],
|
||||
'ENABLE_MKTG_SITE': feature_flags[1]
|
||||
}):
|
||||
course_details_url = get_url(self.course.id)
|
||||
resp = self.client.get_html(course_details_url)
|
||||
self.assertEqual(
|
||||
feature_flags[2],
|
||||
'<h3 id="heading-entrance-exam">' in resp.content
|
||||
)
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class CourseGradingTest(CourseTestCase):
|
||||
@@ -856,7 +771,7 @@ class CourseMetadataEditingTest(CourseTestCase):
|
||||
{
|
||||
"advertised_start": {"value": "start A"},
|
||||
"days_early_for_beta": {"value": 2},
|
||||
"advanced_modules": {"value": ['combinedopenended']},
|
||||
"advanced_modules": {"value": ['notes']},
|
||||
},
|
||||
user=self.user
|
||||
)
|
||||
@@ -866,7 +781,7 @@ class CourseMetadataEditingTest(CourseTestCase):
|
||||
|
||||
# Tab gets tested in test_advanced_settings_munge_tabs
|
||||
self.assertIn('advanced_modules', test_model, 'Missing advanced_modules')
|
||||
self.assertEqual(test_model['advanced_modules']['value'], ['combinedopenended'], 'advanced_module is not updated')
|
||||
self.assertEqual(test_model['advanced_modules']['value'], ['notes'], 'advanced_module is not updated')
|
||||
|
||||
def test_validate_from_json_wrong_inputs(self):
|
||||
# input incorrectly formatted data
|
||||
@@ -990,48 +905,21 @@ class CourseMetadataEditingTest(CourseTestCase):
|
||||
"""
|
||||
Test that adding and removing specific advanced components adds and removes tabs.
|
||||
"""
|
||||
open_ended_tab = {"type": "open_ended", "name": "Open Ended Panel"}
|
||||
peer_grading_tab = {"type": "peer_grading", "name": "Peer grading"}
|
||||
|
||||
# First ensure that none of the tabs are visible
|
||||
self.assertNotIn(open_ended_tab, self.course.tabs)
|
||||
self.assertNotIn(peer_grading_tab, self.course.tabs)
|
||||
self.assertNotIn(self.notes_tab, self.course.tabs)
|
||||
|
||||
# Now add the "combinedopenended" component and verify that the tab has been added
|
||||
self.client.ajax_post(self.course_setting_url, {
|
||||
ADVANCED_COMPONENT_POLICY_KEY: {"value": ["combinedopenended"]}
|
||||
})
|
||||
course = modulestore().get_course(self.course.id)
|
||||
self.assertIn(open_ended_tab, course.tabs)
|
||||
self.assertIn(peer_grading_tab, course.tabs)
|
||||
self.assertNotIn(self.notes_tab, course.tabs)
|
||||
|
||||
# Now enable student notes and verify that the "My Notes" tab has also been added
|
||||
self.client.ajax_post(self.course_setting_url, {
|
||||
ADVANCED_COMPONENT_POLICY_KEY: {"value": ["combinedopenended", "notes"]}
|
||||
})
|
||||
course = modulestore().get_course(self.course.id)
|
||||
self.assertIn(open_ended_tab, course.tabs)
|
||||
self.assertIn(peer_grading_tab, course.tabs)
|
||||
self.assertIn(self.notes_tab, course.tabs)
|
||||
|
||||
# Now remove the "combinedopenended" component and verify that the tab is gone
|
||||
# Now enable student notes and verify that the "My Notes" tab has been added
|
||||
self.client.ajax_post(self.course_setting_url, {
|
||||
ADVANCED_COMPONENT_POLICY_KEY: {"value": ["notes"]}
|
||||
})
|
||||
course = modulestore().get_course(self.course.id)
|
||||
self.assertNotIn(open_ended_tab, course.tabs)
|
||||
self.assertNotIn(peer_grading_tab, course.tabs)
|
||||
self.assertIn(self.notes_tab, course.tabs)
|
||||
|
||||
# Finally disable student notes and verify that the "My Notes" tab is gone
|
||||
# Disable student notes and verify that the "My Notes" tab is gone
|
||||
self.client.ajax_post(self.course_setting_url, {
|
||||
ADVANCED_COMPONENT_POLICY_KEY: {"value": [""]}
|
||||
})
|
||||
course = modulestore().get_course(self.course.id)
|
||||
self.assertNotIn(open_ended_tab, course.tabs)
|
||||
self.assertNotIn(peer_grading_tab, course.tabs)
|
||||
self.assertNotIn(self.notes_tab, course.tabs)
|
||||
|
||||
def test_advanced_components_munge_tabs_validation_failure(self):
|
||||
@@ -1067,40 +955,6 @@ class CourseMetadataEditingTest(CourseTestCase):
|
||||
tab_list.append(self.notes_tab)
|
||||
self.assertEqual(tab_list, course.tabs)
|
||||
|
||||
@override_settings(FEATURES={'CERTIFICATES_HTML_VIEW': True})
|
||||
def test_web_view_certifcate_configuration_settings(self):
|
||||
"""
|
||||
Test that has_cert_config is updated based on cert_html_view_enabled setting.
|
||||
"""
|
||||
test_model = CourseMetadata.update_from_json(
|
||||
self.course,
|
||||
{
|
||||
"cert_html_view_enabled": {"value": "true"}
|
||||
},
|
||||
user=self.user
|
||||
)
|
||||
self.assertIn('cert_html_view_enabled', test_model)
|
||||
url = get_url(self.course.id)
|
||||
response = self.client.get_json(url)
|
||||
course_detail_json = json.loads(response.content)
|
||||
self.assertFalse(course_detail_json['has_cert_config'])
|
||||
|
||||
# Now add a certificate configuration
|
||||
certificates = [
|
||||
{
|
||||
'id': 1,
|
||||
'name': 'Certificate Config Name',
|
||||
'course_title': 'Title override',
|
||||
'signatories': [],
|
||||
'is_active': True
|
||||
}
|
||||
]
|
||||
self.course.certificates = {'certificates': certificates}
|
||||
modulestore().update_item(self.course, self.user.id)
|
||||
response = self.client.get_json(url)
|
||||
course_detail_json = json.loads(response.content)
|
||||
self.assertTrue(course_detail_json['has_cert_config'])
|
||||
|
||||
|
||||
class CourseGraderUpdatesTest(CourseTestCase):
|
||||
"""
|
||||
|
||||
@@ -15,6 +15,7 @@ from unittest import skip
|
||||
from django.conf import settings
|
||||
|
||||
from course_modes.models import CourseMode
|
||||
from openedx.core.djangoapps.models.course_details import CourseDetails
|
||||
from xmodule.library_tools import normalize_key_for_search
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.django import SignalHandler, modulestore
|
||||
@@ -192,26 +193,6 @@ class MixedWithOptionsTestCase(MixedSplitTestCase):
|
||||
with store.branch_setting(ModuleStoreEnum.Branch.draft_preferred):
|
||||
store.update_item(item, ModuleStoreEnum.UserID.test)
|
||||
|
||||
def update_about_item(self, store, about_key, data):
|
||||
"""
|
||||
Update the about item with the new data blob. If data is None, then
|
||||
delete the about item.
|
||||
"""
|
||||
temploc = self.course.id.make_usage_key('about', about_key)
|
||||
if data is None:
|
||||
try:
|
||||
self.delete_item(store, temploc)
|
||||
# Ignore an attempt to delete an item that doesn't exist
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
about_item = store.get_item(temploc)
|
||||
except ItemNotFoundError:
|
||||
about_item = store.create_xblock(self.course.runtime, self.course.id, 'about', about_key)
|
||||
about_item.data = data
|
||||
store.update_item(about_item, ModuleStoreEnum.UserID.test, allow_not_found=True)
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class TestCoursewareSearchIndexer(MixedWithOptionsTestCase):
|
||||
@@ -487,7 +468,9 @@ class TestCoursewareSearchIndexer(MixedWithOptionsTestCase):
|
||||
def _test_course_about_store_index(self, store):
|
||||
""" Test that informational properties in the about store end up in the course_info index """
|
||||
short_description = "Not just anybody"
|
||||
self.update_about_item(store, "short_description", short_description)
|
||||
CourseDetails.update_about_item(
|
||||
self.course, "short_description", short_description, ModuleStoreEnum.UserID.test, store
|
||||
)
|
||||
self.reindex_course(store)
|
||||
response = self.searcher.search(
|
||||
doc_type=CourseAboutSearchIndexer.DISCOVERY_DOCUMENT_TYPE,
|
||||
|
||||
@@ -219,26 +219,6 @@ class ContentStoreImportTest(SignalDisconnectTestMixin, ModuleStoreTestCase):
|
||||
conditional_module.show_tag_list
|
||||
)
|
||||
|
||||
def test_rewrite_reference(self):
|
||||
module_store = modulestore()
|
||||
target_id = module_store.make_course_key('testX', 'peergrading_copy', 'copy_run')
|
||||
import_course_from_xml(
|
||||
module_store,
|
||||
self.user.id,
|
||||
TEST_DATA_DIR,
|
||||
['open_ended'],
|
||||
target_id=target_id,
|
||||
create_if_not_present=True
|
||||
)
|
||||
peergrading_module = module_store.get_item(
|
||||
target_id.make_usage_key('peergrading', 'PeerGradingLinked')
|
||||
)
|
||||
self.assertIsNotNone(peergrading_module)
|
||||
self.assertEqual(
|
||||
target_id.make_usage_key('combinedopenended', 'SampleQuestion'),
|
||||
peergrading_module.link_to_location
|
||||
)
|
||||
|
||||
def test_rewrite_reference_value_dict_published(self):
|
||||
"""
|
||||
Test rewriting references in ReferenceValueDict, specifically with published content.
|
||||
|
||||
@@ -387,7 +387,7 @@ class TestLibraries(LibraryTestCase):
|
||||
html_block = modulestore().get_item(lc_block.children[0])
|
||||
self.assertEqual(html_block.data, data2)
|
||||
|
||||
@patch("xmodule.library_tools.SearchEngine.get_search_engine", Mock(return_value=None))
|
||||
@patch("xmodule.library_tools.SearchEngine.get_search_engine", Mock(return_value=None, autospec=True))
|
||||
def test_refreshes_children_if_capa_type_change(self):
|
||||
""" Tests that children are automatically refreshed if capa type field changes """
|
||||
name1, name2 = "Option Problem", "Multiple Choice Problem"
|
||||
|
||||
@@ -110,55 +110,6 @@ class ExtraPanelTabTestCase(TestCase):
|
||||
return course
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class CourseImageTestCase(ModuleStoreTestCase):
|
||||
"""Tests for course image URLs."""
|
||||
|
||||
def verify_url(self, expected_url, actual_url):
|
||||
"""
|
||||
Helper method for verifying the URL is as expected.
|
||||
"""
|
||||
if not expected_url.startswith("/"):
|
||||
expected_url = "/" + expected_url
|
||||
self.assertEquals(expected_url, actual_url)
|
||||
|
||||
def test_get_image_url(self):
|
||||
"""Test image URL formatting."""
|
||||
course = CourseFactory.create()
|
||||
self.verify_url(
|
||||
unicode(course.id.make_asset_key('asset', course.course_image)),
|
||||
utils.course_image_url(course)
|
||||
)
|
||||
|
||||
def test_non_ascii_image_name(self):
|
||||
""" Verify that non-ascii image names are cleaned """
|
||||
course_image = u'before_\N{SNOWMAN}_after.jpg'
|
||||
course = CourseFactory.create(course_image=course_image)
|
||||
self.verify_url(
|
||||
unicode(course.id.make_asset_key('asset', course_image.replace(u'\N{SNOWMAN}', '_'))),
|
||||
utils.course_image_url(course)
|
||||
)
|
||||
|
||||
def test_spaces_in_image_name(self):
|
||||
""" Verify that image names with spaces in them are cleaned """
|
||||
course_image = u'before after.jpg'
|
||||
course = CourseFactory.create(course_image=u'before after.jpg')
|
||||
self.verify_url(
|
||||
unicode(course.id.make_asset_key('asset', course_image.replace(" ", "_"))),
|
||||
utils.course_image_url(course)
|
||||
)
|
||||
|
||||
@ddt.data(ModuleStoreEnum.Type.split, ModuleStoreEnum.Type.mongo)
|
||||
def test_empty_image_name(self, default_store):
|
||||
""" Verify that empty image names are cleaned """
|
||||
course_image = u''
|
||||
course = CourseFactory.create(course_image=course_image, default_store=default_store)
|
||||
self.assertEquals(
|
||||
course_image,
|
||||
utils.course_image_url(course),
|
||||
)
|
||||
|
||||
|
||||
class XBlockVisibilityTestCase(ModuleStoreTestCase):
|
||||
"""Tests for xblock visibility for students."""
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ Common utility functions useful throughout the contentstore
|
||||
"""
|
||||
|
||||
import logging
|
||||
from opaque_keys import InvalidKeyError
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pytz import UTC
|
||||
@@ -14,7 +13,6 @@ from django.utils.translation import ugettext as _
|
||||
from django_comment_common.models import assign_default_role
|
||||
from django_comment_common.utils import seed_permissions_roles
|
||||
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
@@ -158,16 +156,6 @@ def get_lms_link_for_certificate_web_view(user_id, course_key, mode):
|
||||
)
|
||||
|
||||
|
||||
def course_image_url(course):
|
||||
"""Returns the image url for the course."""
|
||||
try:
|
||||
loc = StaticContent.compute_location(course.location.course_key, course.course_image)
|
||||
except InvalidKeyError:
|
||||
return ''
|
||||
path = StaticContent.serialize_asset_key_with_slash(loc)
|
||||
return path
|
||||
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
def is_currently_visible_to_students(xblock):
|
||||
"""
|
||||
@@ -314,25 +302,6 @@ def reverse_usage_url(handler_name, usage_key, kwargs=None):
|
||||
return reverse_url(handler_name, 'usage_key_string', usage_key, kwargs)
|
||||
|
||||
|
||||
def has_active_web_certificate(course):
|
||||
"""
|
||||
Returns True if given course has active web certificate configuration.
|
||||
If given course has no active web certificate configuration returns False.
|
||||
Returns None If `CERTIFICATES_HTML_VIEW` is not enabled of course has not enabled
|
||||
`cert_html_view_enabled` settings.
|
||||
"""
|
||||
cert_config = None
|
||||
if settings.FEATURES.get('CERTIFICATES_HTML_VIEW', False) and course.cert_html_view_enabled:
|
||||
cert_config = False
|
||||
certificates = getattr(course, 'certificates', {})
|
||||
configurations = certificates.get('certificates', [])
|
||||
for config in configurations:
|
||||
if config.get('is_active'):
|
||||
cert_config = True
|
||||
break
|
||||
return cert_config
|
||||
|
||||
|
||||
def get_user_partition_info(xblock, schemes=None, course=None):
|
||||
"""
|
||||
Retrieve user partition information for an XBlock for display in editors.
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
# Disable warnings about import from wildcard
|
||||
# All files below declare exports with __all__
|
||||
from .assets import *
|
||||
from .checklist import *
|
||||
from .component import *
|
||||
from .course import *
|
||||
from .entrance_exam import *
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
import json
|
||||
import copy
|
||||
|
||||
from util.json_request import JsonResponse
|
||||
from django.http import HttpResponseBadRequest
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from django.views.decorators.csrf import ensure_csrf_cookie
|
||||
from edxmako.shortcuts import render_to_response
|
||||
from django.http import HttpResponseNotFound
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from contentstore.utils import reverse_course_url
|
||||
|
||||
from student.auth import has_course_author_access
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
|
||||
from django.utils.translation import ugettext
|
||||
|
||||
__all__ = ['checklists_handler']
|
||||
|
||||
|
||||
@require_http_methods(("GET", "POST", "PUT"))
|
||||
@login_required
|
||||
@ensure_csrf_cookie
|
||||
def checklists_handler(request, course_key_string, checklist_index=None):
|
||||
"""
|
||||
The restful handler for checklists.
|
||||
|
||||
GET
|
||||
html: return html page for all checklists
|
||||
json: return json representing all checklists. checklist_index is not supported for GET at this time.
|
||||
POST or PUT
|
||||
json: updates the checked state for items within a particular checklist. checklist_index is required.
|
||||
"""
|
||||
course_key = CourseKey.from_string(course_key_string)
|
||||
if not has_course_author_access(request.user, course_key):
|
||||
raise PermissionDenied()
|
||||
|
||||
course_module = modulestore().get_course(course_key)
|
||||
|
||||
json_request = 'application/json' in request.META.get('HTTP_ACCEPT', 'application/json')
|
||||
if request.method == 'GET':
|
||||
# If course was created before checklists were introduced, copy them over
|
||||
# from the template.
|
||||
if not course_module.checklists:
|
||||
course_module.checklists = CourseDescriptor.checklists.default
|
||||
modulestore().update_item(course_module, request.user.id)
|
||||
|
||||
expanded_checklists = expand_all_action_urls(course_module)
|
||||
if json_request:
|
||||
return JsonResponse(expanded_checklists)
|
||||
else:
|
||||
handler_url = reverse_course_url('checklists_handler', course_key)
|
||||
return render_to_response('checklists.html',
|
||||
{
|
||||
'handler_url': handler_url,
|
||||
# context_course is used by analytics
|
||||
'context_course': course_module,
|
||||
'checklists': expanded_checklists
|
||||
})
|
||||
elif json_request:
|
||||
# Can now assume POST or PUT because GET handled above.
|
||||
if checklist_index is not None and 0 <= int(checklist_index) < len(course_module.checklists):
|
||||
index = int(checklist_index)
|
||||
persisted_checklist = course_module.checklists[index]
|
||||
modified_checklist = json.loads(request.body)
|
||||
# Only thing the user can modify is the "checked" state.
|
||||
# We don't want to persist what comes back from the client because it will
|
||||
# include the expanded action URLs (which are non-portable).
|
||||
for item_index, item in enumerate(modified_checklist.get('items')):
|
||||
persisted_checklist['items'][item_index]['is_checked'] = item['is_checked']
|
||||
# seeming noop which triggers kvs to record that the metadata is
|
||||
# not default
|
||||
course_module.checklists = course_module.checklists
|
||||
course_module.save()
|
||||
modulestore().update_item(course_module, request.user.id)
|
||||
expanded_checklist = expand_checklist_action_url(course_module, persisted_checklist)
|
||||
return JsonResponse(localize_checklist_text(expanded_checklist))
|
||||
else:
|
||||
return HttpResponseBadRequest(
|
||||
("Could not save checklist state because the checklist index "
|
||||
"was out of range or unspecified."),
|
||||
content_type="text/plain"
|
||||
)
|
||||
else:
|
||||
return HttpResponseNotFound()
|
||||
|
||||
|
||||
def expand_all_action_urls(course_module):
|
||||
"""
|
||||
Gets the checklists out of the course module and expands their action urls.
|
||||
|
||||
Returns a copy of the checklists with modified urls, without modifying the persisted version
|
||||
of the checklists.
|
||||
"""
|
||||
expanded_checklists = []
|
||||
for checklist in course_module.checklists:
|
||||
expanded_checklists.append(localize_checklist_text(expand_checklist_action_url(course_module, checklist)))
|
||||
return expanded_checklists
|
||||
|
||||
|
||||
def expand_checklist_action_url(course_module, checklist):
|
||||
"""
|
||||
Expands the action URLs for a given checklist and returns the modified version.
|
||||
|
||||
The method does a copy of the input checklist and does not modify the input argument.
|
||||
"""
|
||||
expanded_checklist = copy.deepcopy(checklist)
|
||||
|
||||
urlconf_map = {
|
||||
"ManageUsers": "course_team_handler",
|
||||
"CourseOutline": "course_handler",
|
||||
"SettingsDetails": "settings_handler",
|
||||
"SettingsGrading": "grading_handler",
|
||||
}
|
||||
|
||||
for item in expanded_checklist.get('items'):
|
||||
action_url = item.get('action_url')
|
||||
if action_url in urlconf_map:
|
||||
item['action_url'] = reverse_course_url(urlconf_map[action_url], course_module.id)
|
||||
|
||||
return expanded_checklist
|
||||
|
||||
|
||||
def localize_checklist_text(checklist):
|
||||
"""
|
||||
Localize texts for a given checklist and returns the modified version.
|
||||
|
||||
The method does an in-place operation so the input checklist is modified directly.
|
||||
"""
|
||||
# Localize checklist name
|
||||
checklist['short_description'] = ugettext(checklist['short_description']) # pylint: disable=translation-of-non-string
|
||||
|
||||
# Localize checklist items
|
||||
for item in checklist.get('items'):
|
||||
item['short_description'] = ugettext(item['short_description']) # pylint: disable=translation-of-non-string
|
||||
item['long_description'] = ugettext(item['long_description']) # pylint: disable=translation-of-non-string
|
||||
item['action_text'] = ugettext(item['action_text']) # pylint: disable=translation-of-non-string
|
||||
|
||||
return checklist
|
||||
@@ -30,11 +30,11 @@ from student.auth import has_course_author_access
|
||||
from django.utils.translation import ugettext as _
|
||||
from models.settings.course_grading import CourseGradingModel
|
||||
|
||||
__all__ = ['OPEN_ENDED_COMPONENT_TYPES',
|
||||
'ADVANCED_COMPONENT_POLICY_KEY',
|
||||
'container_handler',
|
||||
'component_handler'
|
||||
]
|
||||
__all__ = [
|
||||
'ADVANCED_COMPONENT_POLICY_KEY',
|
||||
'container_handler',
|
||||
'component_handler'
|
||||
]
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -43,7 +43,6 @@ COMPONENT_TYPES = ['discussion', 'html', 'problem', 'video']
|
||||
|
||||
# Constants for determining if these components should be enabled for this course
|
||||
SPLIT_TEST_COMPONENT_TYPE = 'split_test'
|
||||
OPEN_ENDED_COMPONENT_TYPES = ["combinedopenended", "peergrading"]
|
||||
NOTE_COMPONENT_TYPES = ['notes']
|
||||
|
||||
if settings.FEATURES.get('ALLOW_ALL_ADVANCED_COMPONENTS'):
|
||||
|
||||
@@ -58,16 +58,18 @@ from course_action_state.models import CourseRerunState, CourseRerunUIStateManag
|
||||
from course_creators.views import get_course_creator_status, add_user_with_status_unrequested
|
||||
from edxmako.shortcuts import render_to_response
|
||||
from microsite_configuration import microsite
|
||||
from models.settings.course_details import CourseDetails, CourseSettingsEncoder
|
||||
from models.settings.course_grading import CourseGradingModel
|
||||
from models.settings.course_metadata import CourseMetadata
|
||||
from models.settings.encoder import CourseSettingsEncoder
|
||||
from openedx.core.djangoapps.content.course_structures.api.v0 import api, errors
|
||||
from openedx.core.djangoapps.credit.api import is_credit_course, get_credit_requirements
|
||||
from openedx.core.djangoapps.credit.tasks import update_credit_course_requirements
|
||||
from openedx.core.djangoapps.models.course_details import CourseDetails
|
||||
from openedx.core.djangoapps.programs.models import ProgramsApiConfig
|
||||
from openedx.core.djangoapps.programs.utils import get_programs
|
||||
from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
|
||||
from openedx.core.lib.course_tabs import CourseTabPluginManager
|
||||
from openedx.core.lib.courses import course_image_url
|
||||
from openedx.core.lib.js_utils import escape_json_dumps
|
||||
from student import auth
|
||||
from student.auth import has_course_author_access, has_studio_write_access, has_studio_read_access
|
||||
@@ -82,6 +84,11 @@ from util.milestones_helpers import (
|
||||
is_valid_course_key,
|
||||
set_prerequisite_courses,
|
||||
)
|
||||
from util.organizations_helpers import (
|
||||
add_organization_course,
|
||||
get_organization_by_short_name,
|
||||
organizations_enabled,
|
||||
)
|
||||
from util.string_utils import _has_non_ascii_characters
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
from xmodule.course_module import CourseFields
|
||||
@@ -736,8 +743,17 @@ def _create_new_course(request, org, number, run, fields):
|
||||
Returns the URL for the course overview page.
|
||||
Raises DuplicateCourseError if the course already exists
|
||||
"""
|
||||
org_data = get_organization_by_short_name(org)
|
||||
if not org_data and organizations_enabled():
|
||||
return JsonResponse(
|
||||
{'error': _('You must link this course to an organization in order to continue. '
|
||||
'Organization you selected does not exist in the system, '
|
||||
'you will need to add it to the system')},
|
||||
status=400
|
||||
)
|
||||
store_for_new_course = modulestore().default_modulestore.get_modulestore_type()
|
||||
new_course = create_new_course_in_store(store_for_new_course, request.user, org, number, run, fields)
|
||||
add_organization_course(org_data, new_course.id)
|
||||
return JsonResponse({
|
||||
'url': reverse_course_url('course_handler', new_course.id),
|
||||
'course_key': unicode(new_course.id),
|
||||
@@ -939,7 +955,7 @@ def settings_handler(request, course_key_string):
|
||||
'context_course': course_module,
|
||||
'course_locator': course_key,
|
||||
'lms_link_for_about_page': utils.get_lms_link_for_about_page(course_key),
|
||||
'course_image_url': utils.course_image_url(course_module),
|
||||
'course_image_url': course_image_url(course_module),
|
||||
'details_url': reverse_course_url('settings_handler', course_key),
|
||||
'about_page_editable': about_page_editable,
|
||||
'short_description_editable': short_description_editable,
|
||||
|
||||
23
cms/djangoapps/contentstore/views/organization.py
Normal file
23
cms/djangoapps/contentstore/views/organization.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""Organizations views for use with Studio."""
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.generic import View
|
||||
from django.http import HttpResponse
|
||||
|
||||
from openedx.core.lib.js_utils import escape_json_dumps
|
||||
from util.organizations_helpers import get_organizations
|
||||
|
||||
|
||||
class OrganizationListView(View):
|
||||
"""View rendering organization list as json.
|
||||
|
||||
This view renders organization list json which is used in org
|
||||
autocomplete while creating new course.
|
||||
"""
|
||||
|
||||
@method_decorator(login_required)
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""Returns organization list as json."""
|
||||
organizations = get_organizations()
|
||||
org_names_list = [(org["short_name"]) for org in organizations]
|
||||
return HttpResponse(escape_json_dumps(org_names_list), content_type='application/json; charset=utf-8')
|
||||
@@ -1,12 +1,13 @@
|
||||
"""Programs views for use with Studio."""
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.http import Http404
|
||||
from django.http import Http404, JsonResponse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.generic import View
|
||||
|
||||
from edxmako.shortcuts import render_to_response
|
||||
from openedx.core.djangoapps.programs.models import ProgramsApiConfig
|
||||
from openedx.core.lib.token_utils import get_id_token
|
||||
|
||||
|
||||
class ProgramAuthoringView(View):
|
||||
@@ -18,13 +19,6 @@ class ProgramAuthoringView(View):
|
||||
"""
|
||||
|
||||
@method_decorator(login_required)
|
||||
def dispatch(self, *args, **kwargs):
|
||||
"""Relays requests to matching methods.
|
||||
|
||||
Decorated to require login before accessing the authoring app.
|
||||
"""
|
||||
return super(ProgramAuthoringView, self).dispatch(*args, **kwargs)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""Populate the template context with values required for the authoring app to run."""
|
||||
programs_config = ProgramsApiConfig.current()
|
||||
@@ -34,7 +28,21 @@ class ProgramAuthoringView(View):
|
||||
'show_programs_header': programs_config.is_studio_tab_enabled,
|
||||
'authoring_app_config': programs_config.authoring_app_config,
|
||||
'programs_api_url': programs_config.public_api_url,
|
||||
'programs_token_url': reverse('programs_id_token'),
|
||||
'studio_home_url': reverse('home'),
|
||||
})
|
||||
else:
|
||||
raise Http404
|
||||
|
||||
|
||||
class ProgramsIdTokenView(View):
|
||||
"""Provides id tokens to JavaScript clients for use with the Programs API."""
|
||||
|
||||
@method_decorator(login_required)
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""Generate and return a token, if the integration is enabled."""
|
||||
if ProgramsApiConfig.current().is_studio_tab_enabled:
|
||||
id_token = get_id_token(request.user, 'programs')
|
||||
return JsonResponse({'id_token': id_token})
|
||||
else:
|
||||
raise Http404
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
""" Unit tests for checklist methods in views.py. """
|
||||
from contentstore.utils import reverse_course_url
|
||||
from contentstore.views.checklist import expand_checklist_action_url
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
import json
|
||||
from contentstore.tests.utils import CourseTestCase
|
||||
|
||||
|
||||
class ChecklistTestCase(CourseTestCase):
|
||||
""" Test for checklist get and put methods. """
|
||||
def setUp(self):
|
||||
""" Creates the test course. """
|
||||
super(ChecklistTestCase, self).setUp()
|
||||
self.course = CourseFactory.create(org='mitX', number='333', display_name='Checklists Course')
|
||||
self.checklists_url = self.get_url()
|
||||
|
||||
def get_url(self, checklist_index=None):
|
||||
url_args = {'checklist_index': checklist_index} if checklist_index else None
|
||||
return reverse_course_url('checklists_handler', self.course.id, kwargs=url_args)
|
||||
|
||||
def get_persisted_checklists(self):
|
||||
""" Returns the checklists as persisted in the modulestore. """
|
||||
return modulestore().get_item(self.course.location).checklists
|
||||
|
||||
def compare_checklists(self, persisted, request):
|
||||
"""
|
||||
Handles url expansion as possible difference and descends into guts
|
||||
"""
|
||||
self.assertEqual(persisted['short_description'], request['short_description'])
|
||||
expanded_checklist = expand_checklist_action_url(self.course, persisted)
|
||||
for pers, req in zip(expanded_checklist['items'], request['items']):
|
||||
self.assertEqual(pers['short_description'], req['short_description'])
|
||||
self.assertEqual(pers['long_description'], req['long_description'])
|
||||
self.assertEqual(pers['is_checked'], req['is_checked'])
|
||||
self.assertEqual(pers['action_url'], req['action_url'])
|
||||
self.assertEqual(pers['action_text'], req['action_text'])
|
||||
self.assertEqual(pers['action_external'], req['action_external'])
|
||||
|
||||
def test_get_checklists(self):
|
||||
""" Tests the get checklists method and URL expansion. """
|
||||
response = self.client.get(self.checklists_url)
|
||||
self.assertContains(response, "Getting Started With Studio")
|
||||
# Verify expansion of action URL happened.
|
||||
self.assertContains(response, 'course_team/mitX/333/Checklists_Course')
|
||||
# Verify persisted checklist does NOT have expanded URL.
|
||||
checklist_0 = self.get_persisted_checklists()[0]
|
||||
self.assertEqual('ManageUsers', get_action_url(checklist_0, 0))
|
||||
payload = response.content
|
||||
|
||||
# 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().update_item(self.course, self.user.id)
|
||||
self.assertEqual(self.get_persisted_checklists(), None)
|
||||
response = self.client.get(self.checklists_url)
|
||||
self.assertEqual(payload, response.content)
|
||||
|
||||
def test_get_checklists_html(self):
|
||||
""" Tests getting the HTML template for the checklists page). """
|
||||
response = self.client.get(self.checklists_url, HTTP_ACCEPT='text/html')
|
||||
self.assertContains(response, "Getting Started With Studio")
|
||||
# The HTML generated will define the handler URL (for use by the Backbone model).
|
||||
self.assertContains(response, self.checklists_url)
|
||||
|
||||
def test_update_checklists_no_index(self):
|
||||
""" No checklist index, should return all of them. """
|
||||
returned_checklists = json.loads(self.client.get(self.checklists_url).content)
|
||||
# Verify that persisted checklists do not have expanded action URLs.
|
||||
# compare_checklists will verify that returned_checklists DO have expanded action URLs.
|
||||
pers = self.get_persisted_checklists()
|
||||
self.assertEqual('CourseOutline', get_first_item(pers[1]).get('action_url'))
|
||||
for pay, resp in zip(pers, returned_checklists):
|
||||
self.compare_checklists(pay, resp)
|
||||
|
||||
def test_update_checklists_index_ignored_on_get(self):
|
||||
""" Checklist index ignored on get. """
|
||||
update_url = self.get_url(1)
|
||||
|
||||
returned_checklists = json.loads(self.client.get(update_url).content)
|
||||
for pay, resp in zip(self.get_persisted_checklists(), returned_checklists):
|
||||
self.compare_checklists(pay, resp)
|
||||
|
||||
def test_update_checklists_post_no_index(self):
|
||||
""" No checklist index, will error on post. """
|
||||
response = self.client.post(self.checklists_url)
|
||||
self.assertContains(response, 'Could not save checklist', status_code=400)
|
||||
|
||||
def test_update_checklists_index_out_of_range(self):
|
||||
""" Checklist index out of range, will error on post. """
|
||||
update_url = self.get_url(100)
|
||||
|
||||
response = self.client.post(update_url)
|
||||
self.assertContains(response, 'Could not save checklist', status_code=400)
|
||||
|
||||
def test_update_checklists_index(self):
|
||||
""" Check that an update of a particular checklist works. """
|
||||
update_url = self.get_url(1)
|
||||
|
||||
payload = self.course.checklists[1]
|
||||
self.assertFalse(get_first_item(payload).get('is_checked'))
|
||||
self.assertEqual('CourseOutline', get_first_item(payload).get('action_url'))
|
||||
get_first_item(payload)['is_checked'] = True
|
||||
|
||||
returned_checklist = json.loads(self.client.ajax_post(update_url, payload).content)
|
||||
self.assertTrue(get_first_item(returned_checklist).get('is_checked'))
|
||||
persisted_checklist = self.get_persisted_checklists()[1]
|
||||
# Verify that persisted checklist does not have expanded action URLs.
|
||||
# compare_checklists will verify that returned_checklist DOES have expanded action URLs.
|
||||
self.assertEqual('CourseOutline', get_first_item(persisted_checklist).get('action_url'))
|
||||
self.compare_checklists(persisted_checklist, returned_checklist)
|
||||
|
||||
def test_update_checklists_delete_unsupported(self):
|
||||
""" Delete operation is not supported. """
|
||||
update_url = self.get_url(100)
|
||||
response = self.client.delete(update_url)
|
||||
self.assertEqual(response.status_code, 405)
|
||||
|
||||
def test_expand_checklist_action_url(self):
|
||||
"""
|
||||
Tests the method to expand checklist action url.
|
||||
"""
|
||||
|
||||
def test_expansion(checklist, index, stored, expanded):
|
||||
"""
|
||||
Tests that the expected expanded value is returned for the item at the given index.
|
||||
|
||||
Also verifies that the original checklist is not modified.
|
||||
"""
|
||||
self.assertEqual(get_action_url(checklist, index), stored)
|
||||
expanded_checklist = expand_checklist_action_url(self.course, checklist)
|
||||
self.assertEqual(get_action_url(expanded_checklist, index), expanded)
|
||||
# Verify no side effect in the original list.
|
||||
self.assertEqual(get_action_url(checklist, index), stored)
|
||||
|
||||
test_expansion(self.course.checklists[0], 0, 'ManageUsers', '/course_team/mitX/333/Checklists_Course')
|
||||
test_expansion(self.course.checklists[1], 1, 'CourseOutline', '/course/mitX/333/Checklists_Course')
|
||||
test_expansion(self.course.checklists[2], 0, 'http://help.edge.edx.org/', 'http://help.edge.edx.org/')
|
||||
|
||||
|
||||
def get_first_item(checklist):
|
||||
""" Returns the first item from the checklist. """
|
||||
return checklist['items'][0]
|
||||
|
||||
|
||||
def get_action_url(checklist, index):
|
||||
"""
|
||||
Returns the action_url for the item at the specified index in the given checklist.
|
||||
"""
|
||||
return checklist['items'][index]['action_url']
|
||||
@@ -10,6 +10,7 @@ import pytz
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.test.utils import override_settings
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
from contentstore.courseware_index import CoursewareSearchIndexer, SearchIndexingError
|
||||
@@ -440,6 +441,7 @@ class TestCourseOutline(CourseTestCase):
|
||||
info['block_types_enabled'],
|
||||
any(component in advanced_modules for component in deprecated_block_types)
|
||||
)
|
||||
|
||||
self.assertItemsEqual(info['blocks'], expected_blocks)
|
||||
self.assertEqual(
|
||||
info['advance_settings_url'],
|
||||
@@ -455,27 +457,29 @@ class TestCourseOutline(CourseTestCase):
|
||||
"""
|
||||
Verify deprecated warning info for single deprecated feature.
|
||||
"""
|
||||
block_types = settings.DEPRECATED_BLOCK_TYPES
|
||||
course_module = modulestore().get_item(self.course.location)
|
||||
self._create_test_data(course_module, create_blocks=True, block_types=block_types, publish=publish)
|
||||
info = _deprecated_blocks_info(course_module, block_types)
|
||||
self._verify_deprecated_info(
|
||||
course_module.id,
|
||||
course_module.advanced_modules,
|
||||
info,
|
||||
block_types
|
||||
)
|
||||
block_types = ['notes']
|
||||
with override_settings(DEPRECATED_BLOCK_TYPES=block_types):
|
||||
course_module = modulestore().get_item(self.course.location)
|
||||
self._create_test_data(course_module, create_blocks=True, block_types=block_types, publish=publish)
|
||||
info = _deprecated_blocks_info(course_module, block_types)
|
||||
self._verify_deprecated_info(
|
||||
course_module.id,
|
||||
course_module.advanced_modules,
|
||||
info,
|
||||
block_types
|
||||
)
|
||||
|
||||
def test_verify_deprecated_warning_message_with_multiple_features(self):
|
||||
"""
|
||||
Verify deprecated warning info for multiple deprecated features.
|
||||
"""
|
||||
block_types = ['peergrading', 'combinedopenended', 'openassessment']
|
||||
course_module = modulestore().get_item(self.course.location)
|
||||
self._create_test_data(course_module, create_blocks=True, block_types=block_types)
|
||||
block_types = ['notes', 'lti']
|
||||
with override_settings(DEPRECATED_BLOCK_TYPES=block_types):
|
||||
course_module = modulestore().get_item(self.course.location)
|
||||
self._create_test_data(course_module, create_blocks=True, block_types=block_types)
|
||||
|
||||
info = _deprecated_blocks_info(course_module, block_types)
|
||||
self._verify_deprecated_info(course_module.id, course_module.advanced_modules, info, block_types)
|
||||
info = _deprecated_blocks_info(course_module, block_types)
|
||||
self._verify_deprecated_info(course_module.id, course_module.advanced_modules, info, block_types)
|
||||
|
||||
@ddt.data(
|
||||
{'delete_vertical': True},
|
||||
@@ -492,7 +496,7 @@ class TestCourseOutline(CourseTestCase):
|
||||
un-published block(s). This behavior should be same if we delete
|
||||
unpublished vertical or problem.
|
||||
"""
|
||||
block_types = ['peergrading']
|
||||
block_types = ['notes']
|
||||
course_module = modulestore().get_item(self.course.location)
|
||||
|
||||
vertical1 = ItemFactory.create(
|
||||
@@ -500,8 +504,8 @@ class TestCourseOutline(CourseTestCase):
|
||||
)
|
||||
problem1 = ItemFactory.create(
|
||||
parent_location=vertical1.location,
|
||||
category='peergrading',
|
||||
display_name='peergrading problem in vert1',
|
||||
category='notes',
|
||||
display_name='notes problem in vert1',
|
||||
publish_item=False
|
||||
)
|
||||
|
||||
@@ -515,8 +519,8 @@ class TestCourseOutline(CourseTestCase):
|
||||
)
|
||||
ItemFactory.create(
|
||||
parent_location=vertical2.location,
|
||||
category='peergrading',
|
||||
display_name='peergrading problem in vert2',
|
||||
category='notes',
|
||||
display_name='notes problem in vert2',
|
||||
pubish_item=True
|
||||
)
|
||||
# At this point CourseStructure will contain both the above
|
||||
@@ -526,8 +530,8 @@ class TestCourseOutline(CourseTestCase):
|
||||
self.assertItemsEqual(
|
||||
info['blocks'],
|
||||
[
|
||||
[reverse_usage_url('container_handler', vertical1.location), 'peergrading problem in vert1'],
|
||||
[reverse_usage_url('container_handler', vertical2.location), 'peergrading problem in vert2']
|
||||
[reverse_usage_url('container_handler', vertical1.location), 'notes problem in vert1'],
|
||||
[reverse_usage_url('container_handler', vertical2.location), 'notes problem in vert2']
|
||||
]
|
||||
)
|
||||
|
||||
@@ -542,7 +546,7 @@ class TestCourseOutline(CourseTestCase):
|
||||
# There shouldn't be any info present about un-published vertical1
|
||||
self.assertEqual(
|
||||
info['blocks'],
|
||||
[[reverse_usage_url('container_handler', vertical2.location), 'peergrading problem in vert2']]
|
||||
[[reverse_usage_url('container_handler', vertical2.location), 'notes problem in vert2']]
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1388,28 +1388,28 @@ class TestComponentTemplates(CourseTestCase):
|
||||
Test the handling of advanced problem templates.
|
||||
"""
|
||||
problem_templates = self.get_templates_of_type('problem')
|
||||
ora_template = self.get_template(problem_templates, u'Peer Assessment')
|
||||
self.assertIsNotNone(ora_template)
|
||||
self.assertEqual(ora_template.get('category'), 'openassessment')
|
||||
self.assertIsNone(ora_template.get('boilerplate_name', None))
|
||||
circuit_template = self.get_template(problem_templates, u'Circuit Schematic Builder')
|
||||
self.assertIsNotNone(circuit_template)
|
||||
self.assertEqual(circuit_template.get('category'), 'problem')
|
||||
self.assertEqual(circuit_template.get('boilerplate_name'), 'circuitschematic.yaml')
|
||||
|
||||
@patch('django.conf.settings.DEPRECATED_ADVANCED_COMPONENT_TYPES', ["combinedopenended", "peergrading"])
|
||||
def test_ora1_no_advance_component_button(self):
|
||||
@patch('django.conf.settings.DEPRECATED_ADVANCED_COMPONENT_TYPES', ["poll", "survey"])
|
||||
def test_deprecated_no_advance_component_button(self):
|
||||
"""
|
||||
Test that there will be no `Advanced` button on unit page if `combinedopenended` and `peergrading` are
|
||||
deprecated provided that there are only 'combinedopenended', 'peergrading' modules in `Advanced Module List`
|
||||
Test that there will be no `Advanced` button on unit page if units are
|
||||
deprecated provided that they are the only modules in `Advanced Module List`
|
||||
"""
|
||||
self.course.advanced_modules.extend(['combinedopenended', 'peergrading'])
|
||||
self.course.advanced_modules.extend(['poll', 'survey'])
|
||||
templates = get_component_templates(self.course)
|
||||
button_names = [template['display_name'] for template in templates]
|
||||
self.assertNotIn('Advanced', button_names)
|
||||
|
||||
@patch('django.conf.settings.DEPRECATED_ADVANCED_COMPONENT_TYPES', ["combinedopenended", "peergrading"])
|
||||
def test_cannot_create_ora1_problems(self):
|
||||
@patch('django.conf.settings.DEPRECATED_ADVANCED_COMPONENT_TYPES', ["poll", "survey"])
|
||||
def test_cannot_create_deprecated_problems(self):
|
||||
"""
|
||||
Test that we can't create ORA1 problems if `combinedopenended` and `peergrading` are deprecated
|
||||
Test that we can't create problems if they are deprecated
|
||||
"""
|
||||
self.course.advanced_modules.extend(['annotatable', 'combinedopenended', 'peergrading'])
|
||||
self.course.advanced_modules.extend(['annotatable', 'poll', 'survey'])
|
||||
templates = get_component_templates(self.course)
|
||||
button_names = [template['display_name'] for template in templates]
|
||||
self.assertIn('Advanced', button_names)
|
||||
@@ -1418,17 +1418,17 @@ class TestComponentTemplates(CourseTestCase):
|
||||
self.assertEqual(template_display_names, ['Annotation'])
|
||||
|
||||
@patch('django.conf.settings.DEPRECATED_ADVANCED_COMPONENT_TYPES', [])
|
||||
def test_create_ora1_problems(self):
|
||||
def test_create_non_deprecated_problems(self):
|
||||
"""
|
||||
Test that we can create ORA1 problems if `combinedopenended` and `peergrading` are not deprecated
|
||||
Test that we can create problems if they are not deprecated
|
||||
"""
|
||||
self.course.advanced_modules.extend(['annotatable', 'combinedopenended', 'peergrading'])
|
||||
self.course.advanced_modules.extend(['annotatable', 'poll', 'survey'])
|
||||
templates = get_component_templates(self.course)
|
||||
button_names = [template['display_name'] for template in templates]
|
||||
self.assertIn('Advanced', button_names)
|
||||
self.assertEqual(len(templates[0]['templates']), 3)
|
||||
template_display_names = [template['display_name'] for template in templates[0]['templates']]
|
||||
self.assertEqual(template_display_names, ['Annotation', 'Open Response Assessment', 'Peer Grading Interface'])
|
||||
self.assertEqual(template_display_names, ['Annotation', 'Poll', 'Survey'])
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Tests covering the Organizations listing on the Studio home."""
|
||||
import json
|
||||
from mock import patch
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.test import TestCase
|
||||
from util.organizations_helpers import add_organization
|
||||
from student.tests.factories import UserFactory
|
||||
|
||||
|
||||
@patch.dict('django.conf.settings.FEATURES', {'ORGANIZATIONS_APP': True})
|
||||
class TestOrganizationListing(TestCase):
|
||||
"""Verify Organization listing behavior."""
|
||||
@patch.dict('django.conf.settings.FEATURES', {'ORGANIZATIONS_APP': True})
|
||||
def setUp(self):
|
||||
super(TestOrganizationListing, self).setUp()
|
||||
self.staff = UserFactory(is_staff=True)
|
||||
self.client.login(username=self.staff.username, password='test')
|
||||
self.org_names_listing_url = reverse('organizations')
|
||||
self.org_short_names = ["alphaX", "betaX", "orgX"]
|
||||
for index, short_name in enumerate(self.org_short_names):
|
||||
add_organization(organization_data={
|
||||
'name': 'Test Organization %s' % index,
|
||||
'short_name': short_name,
|
||||
'description': 'Testing Organization %s Description' % index,
|
||||
})
|
||||
|
||||
def test_organization_list(self):
|
||||
"""Verify that the organization names list api returns list of organization short names."""
|
||||
response = self.client.get(self.org_names_listing_url, HTTP_ACCEPT='application/json')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
org_names = json.loads(response.content)
|
||||
self.assertEqual(org_names, self.org_short_names)
|
||||
@@ -1,17 +1,20 @@
|
||||
"""Tests covering the Programs listing on the Studio home."""
|
||||
import json
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.urlresolvers import reverse
|
||||
import httpretty
|
||||
import mock
|
||||
from oauth2_provider.tests.factories import ClientFactory
|
||||
from provider.constants import CONFIDENTIAL
|
||||
|
||||
from openedx.core.djangoapps.programs.models import ProgramsApiConfig
|
||||
from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin, ProgramsDataMixin
|
||||
from student.tests.factories import UserFactory
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
|
||||
|
||||
|
||||
class TestProgramListing(ProgramsApiConfigMixin, ProgramsDataMixin, ModuleStoreTestCase):
|
||||
class TestProgramListing(ProgramsApiConfigMixin, ProgramsDataMixin, SharedModuleStoreTestCase):
|
||||
"""Verify Program listing behavior."""
|
||||
def setUp(self):
|
||||
super(TestProgramListing, self).setUp()
|
||||
@@ -70,7 +73,7 @@ class TestProgramListing(ProgramsApiConfigMixin, ProgramsDataMixin, ModuleStoreT
|
||||
self.assertIn(program_name, response.content)
|
||||
|
||||
|
||||
class TestProgramAuthoringView(ProgramsApiConfigMixin, ModuleStoreTestCase):
|
||||
class TestProgramAuthoringView(ProgramsApiConfigMixin, SharedModuleStoreTestCase):
|
||||
"""Verify the behavior of the program authoring app's host view."""
|
||||
def setUp(self):
|
||||
super(TestProgramAuthoringView, self).setUp()
|
||||
@@ -118,3 +121,43 @@ class TestProgramAuthoringView(ProgramsApiConfigMixin, ModuleStoreTestCase):
|
||||
student = UserFactory(is_staff=False)
|
||||
self.client.login(username=student.username, password='test')
|
||||
self._assert_status(404)
|
||||
|
||||
|
||||
class TestProgramsIdTokenView(ProgramsApiConfigMixin, SharedModuleStoreTestCase):
|
||||
"""Tests for the programs id_token endpoint."""
|
||||
|
||||
def setUp(self):
|
||||
super(TestProgramsIdTokenView, self).setUp()
|
||||
self.user = UserFactory()
|
||||
self.client.login(username=self.user.username, password='test')
|
||||
self.path = reverse('programs_id_token')
|
||||
|
||||
def test_config_disabled(self):
|
||||
"""Ensure the endpoint returns 404 when Programs authoring is disabled."""
|
||||
self.create_config(enable_studio_tab=False)
|
||||
response = self.client.get(self.path)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_not_logged_in(self):
|
||||
"""Ensure the endpoint denies access to unauthenticated users."""
|
||||
self.create_config()
|
||||
self.client.logout()
|
||||
response = self.client.get(self.path)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertIn(settings.LOGIN_URL, response['Location'])
|
||||
|
||||
@mock.patch('cms.djangoapps.contentstore.views.program.get_id_token', return_value='test-id-token')
|
||||
def test_config_enabled(self, mock_get_id_token):
|
||||
"""
|
||||
Ensure the endpoint responds with a valid JSON payload when authoring
|
||||
is enabled.
|
||||
"""
|
||||
self.create_config()
|
||||
response = self.client.get(self.path)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = json.loads(response.content)
|
||||
self.assertEqual(payload, {"id_token": "test-id-token"})
|
||||
# this comparison is a little long-handed because we need to compare user instances directly
|
||||
user, client_name = mock_get_id_token.call_args[0]
|
||||
self.assertEqual(user, self.user)
|
||||
self.assertEqual(client_name, "programs")
|
||||
|
||||
@@ -26,7 +26,6 @@ class CourseMetadata(object):
|
||||
'enrollment_end',
|
||||
'tabs',
|
||||
'graceperiod',
|
||||
'checklists',
|
||||
'show_timezone',
|
||||
'format',
|
||||
'graded',
|
||||
|
||||
28
cms/djangoapps/models/settings/encoder.py
Normal file
28
cms/djangoapps/models/settings/encoder.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""
|
||||
CourseSettingsEncoder
|
||||
"""
|
||||
import datetime
|
||||
import json
|
||||
from json.encoder import JSONEncoder
|
||||
|
||||
from opaque_keys.edx.locations import Location
|
||||
from openedx.core.djangoapps.models.course_details import CourseDetails
|
||||
from xmodule.fields import Date
|
||||
|
||||
from .course_grading import CourseGradingModel
|
||||
|
||||
|
||||
class CourseSettingsEncoder(json.JSONEncoder):
|
||||
"""
|
||||
Serialize CourseDetails, CourseGradingModel, datetime, and old
|
||||
Locations
|
||||
"""
|
||||
def default(self, obj): # pylint: disable=method-hidden
|
||||
if isinstance(obj, (CourseDetails, CourseGradingModel)):
|
||||
return obj.__dict__
|
||||
elif isinstance(obj, Location):
|
||||
return obj.dict()
|
||||
elif isinstance(obj, datetime.datetime):
|
||||
return Date().to_json(obj)
|
||||
else:
|
||||
return JSONEncoder.default(self, obj)
|
||||
@@ -81,14 +81,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"OPEN_ENDED_GRADING_INTERFACE": {
|
||||
"grading_controller": "grading_controller",
|
||||
"password": "password",
|
||||
"peer_grading": "peer_grading",
|
||||
"staff_grading": "staff_grading",
|
||||
"url": "http://localhost:18060/",
|
||||
"username": "lms"
|
||||
},
|
||||
"DJFS": {
|
||||
"type": "s3fs",
|
||||
"bucket": "test",
|
||||
|
||||
@@ -104,5 +104,9 @@
|
||||
"THEME_NAME": "",
|
||||
"TIME_ZONE": "America/New_York",
|
||||
"WIKI_ENABLED": true,
|
||||
"OAUTH_OIDC_ISSUER": "https://www.example.com/oauth2"
|
||||
"OAUTH_OIDC_ISSUER": "https://www.example.com/oauth2",
|
||||
"DEPRECATED_BLOCK_TYPES": [
|
||||
"poll",
|
||||
"survey"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -96,6 +96,9 @@ FEATURES['ENABLE_VIDEO_BUMPER'] = True # Enable video bumper in Studio settings
|
||||
# Enable partner support link in Studio footer
|
||||
FEATURES['PARTNER_SUPPORT_EMAIL'] = 'partner-support@example.com'
|
||||
|
||||
# Disable some block types to test block deprecation logic
|
||||
DEPRECATED_BLOCK_TYPES = ['poll', 'survey']
|
||||
|
||||
########################### Entrance Exams #################################
|
||||
FEATURES['ENTRANCE_EXAMS'] = True
|
||||
|
||||
@@ -109,6 +112,8 @@ YOUTUBE['TEXT_API']['url'] = "127.0.0.1:{0}/test_transcripts_youtube/".format(YO
|
||||
|
||||
FEATURES['ENABLE_COURSEWARE_INDEX'] = True
|
||||
FEATURES['ENABLE_LIBRARY_INDEX'] = True
|
||||
|
||||
FEATURES['ORGANIZATIONS_APP'] = True
|
||||
SEARCH_ENGINE = "search.tests.mock_search_engine.MockSearchEngine"
|
||||
# Path at which to store the mock index
|
||||
MOCK_SEARCH_BACKING_FILE = (
|
||||
|
||||
@@ -180,6 +180,8 @@ FEATURES = {
|
||||
|
||||
# Special Exams, aka Timed and Proctored Exams
|
||||
'ENABLE_SPECIAL_EXAMS': False,
|
||||
|
||||
'ORGANIZATIONS_APP': False,
|
||||
}
|
||||
|
||||
ENABLE_JASMINE = False
|
||||
@@ -924,6 +926,9 @@ OPTIONAL_APPS = (
|
||||
|
||||
# milestones
|
||||
'milestones',
|
||||
|
||||
# Organizations App (http://github.com/edx/edx-organizations)
|
||||
'organizations',
|
||||
)
|
||||
|
||||
|
||||
@@ -1001,6 +1006,8 @@ ADVANCED_COMPONENT_TYPES = [
|
||||
'pb-dashboard',
|
||||
'poll',
|
||||
'survey',
|
||||
'activetable',
|
||||
'vectordraw',
|
||||
# Some of the XBlocks from pmitros repos are sometimes prototypes.
|
||||
# Use with caution.
|
||||
'concept', # Concept mapper. See https://github.com/pmitros/ConceptXBlock
|
||||
@@ -1011,8 +1018,6 @@ ADVANCED_COMPONENT_TYPES = [
|
||||
'rate', # Allows up-down voting of course content. See https://github.com/pmitros/RateXBlock
|
||||
|
||||
'split_test',
|
||||
'combinedopenended',
|
||||
'peergrading',
|
||||
'notes',
|
||||
'schoolyourself_review',
|
||||
'schoolyourself_lesson',
|
||||
|
||||
@@ -173,9 +173,6 @@ CACHES = {
|
||||
},
|
||||
}
|
||||
|
||||
# Add apps to Installed apps for testing
|
||||
INSTALLED_APPS += ('openedx.core.djangoapps.call_stack_manager',)
|
||||
|
||||
# hide ratelimit warnings while running tests
|
||||
filterwarnings('ignore', message='No request passed to the backend, unable to rate-limit')
|
||||
|
||||
|
||||
@@ -34,7 +34,6 @@
|
||||
modules: getModulesList([
|
||||
'js/factories/asset_index',
|
||||
'js/factories/base',
|
||||
'js/factories/checklists',
|
||||
'js/factories/container',
|
||||
'js/factories/course',
|
||||
'js/factories/course_create_rerun',
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
define(["backbone", "underscore", "js/models/checklist"],
|
||||
function(Backbone, _, ChecklistModel) {
|
||||
var ChecklistCollection = Backbone.Collection.extend({
|
||||
model : ChecklistModel,
|
||||
|
||||
parse: function(response) {
|
||||
_.each(response,
|
||||
function( element, idx ) {
|
||||
element.id = idx;
|
||||
});
|
||||
|
||||
return response;
|
||||
},
|
||||
|
||||
// Disable caching so the browser back button will work (checklists have links to other
|
||||
// places within Studio).
|
||||
fetch: function (options) {
|
||||
options.cache = false;
|
||||
return Backbone.Collection.prototype.fetch.call(this, options);
|
||||
}
|
||||
});
|
||||
return ChecklistCollection;
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
define([
|
||||
'jquery', 'js/collections/checklist', 'js/views/checklist'
|
||||
], function($, ChecklistCollection, ChecklistView) {
|
||||
'use strict';
|
||||
return function (handlerUrl) {
|
||||
var checklistCollection = new ChecklistCollection(),
|
||||
editor;
|
||||
|
||||
checklistCollection.url = handlerUrl;
|
||||
editor = new ChecklistView({
|
||||
el: $('.course-checklists'),
|
||||
collection: checklistCollection
|
||||
});
|
||||
checklistCollection.fetch({reset: true});
|
||||
};
|
||||
});
|
||||
@@ -91,7 +91,7 @@ define(["domReady", "jquery", "underscore", "js/utils/cancel_on_escape", "js/vie
|
||||
$('.new-course-save').on('click', saveNewCourse);
|
||||
$cancelButton.bind('click', makeCancelHandler('course'));
|
||||
CancelOnEscape($cancelButton);
|
||||
|
||||
CreateCourseUtils.setupOrgAutocomplete();
|
||||
CreateCourseUtils.configureHandlers();
|
||||
};
|
||||
|
||||
|
||||
@@ -41,6 +41,8 @@ define(["jquery", "common/js/spec_helpers/ajax_helpers", "common/js/spec_helpers
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
var redirectSpy = spyOn(ViewUtils, 'redirect');
|
||||
$('.new-course-button').click()
|
||||
AjaxHelpers.expectJsonRequest(requests, 'GET', '/organizations');
|
||||
AjaxHelpers.respondWithJson(requests, ['DemoX', 'DemoX2', 'DemoX3']);
|
||||
fillInFields('DemoX', 'DM101', '2014', 'Demo course');
|
||||
$('.new-course-save').click();
|
||||
AjaxHelpers.expectJsonRequest(requests, 'POST', '/course/', {
|
||||
@@ -53,11 +55,14 @@ define(["jquery", "common/js/spec_helpers/ajax_helpers", "common/js/spec_helpers
|
||||
url: 'dummy_test_url'
|
||||
});
|
||||
expect(redirectSpy).toHaveBeenCalledWith('dummy_test_url');
|
||||
$(".new-course-org").autocomplete("destroy");
|
||||
});
|
||||
|
||||
it("displays an error when saving fails", function () {
|
||||
var requests = AjaxHelpers.requests(this);
|
||||
$('.new-course-button').click();
|
||||
AjaxHelpers.expectJsonRequest(requests, 'GET', '/organizations');
|
||||
AjaxHelpers.respondWithJson(requests, ['DemoX', 'DemoX2', 'DemoX3']);
|
||||
fillInFields('DemoX', 'DM101', '2014', 'Demo course');
|
||||
$('.new-course-save').click();
|
||||
AjaxHelpers.respondWithJson(requests, {
|
||||
@@ -67,6 +72,7 @@ define(["jquery", "common/js/spec_helpers/ajax_helpers", "common/js/spec_helpers
|
||||
expect($('#course_creation_error')).toContainText('error message');
|
||||
expect($('.new-course-save')).toHaveClass('is-disabled');
|
||||
expect($('.new-course-save')).toHaveAttr('aria-disabled', 'true');
|
||||
$(".new-course-org").autocomplete("destroy");
|
||||
});
|
||||
|
||||
it("saves new libraries", function () {
|
||||
|
||||
@@ -31,8 +31,7 @@ define([
|
||||
entrance_exam_enabled : '',
|
||||
entrance_exam_minimum_score_pct: '50',
|
||||
license: null,
|
||||
language: '',
|
||||
has_cert_config: false
|
||||
language: ''
|
||||
},
|
||||
mockSettingsPage = readFixtures('mock/mock-settings-page.underscore');
|
||||
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
define(["js/views/baseview", "underscore", "jquery"], function(BaseView, _, $) {
|
||||
var ChecklistView = BaseView.extend({
|
||||
// takes CMS.Models.Checklists as model
|
||||
|
||||
events : {
|
||||
'click .course-checklist .checklist-title' : "toggleChecklist",
|
||||
'click .course-checklist .task input' : "toggleTask",
|
||||
'click a[rel="external"]' : "popup"
|
||||
},
|
||||
|
||||
initialize : function() {
|
||||
var self = this;
|
||||
this.template = this.loadTemplate('checklist');
|
||||
this.collection.fetch({
|
||||
reset: true,
|
||||
complete: function() {
|
||||
self.render();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
render: function() {
|
||||
// catch potential outside call before template loaded
|
||||
if (!this.template) return this;
|
||||
|
||||
this.$el.empty();
|
||||
|
||||
var self = this;
|
||||
_.each(this.collection.models,
|
||||
function(checklist, index) {
|
||||
self.$el.append(self.renderTemplate(checklist, index));
|
||||
});
|
||||
|
||||
return this;
|
||||
},
|
||||
|
||||
renderTemplate: function (checklist, index) {
|
||||
var checklistItems = checklist.attributes['items'];
|
||||
var itemsChecked = 0;
|
||||
_.each(checklistItems,
|
||||
function(checklist) {
|
||||
if (checklist['is_checked']) {
|
||||
itemsChecked +=1;
|
||||
}
|
||||
});
|
||||
var percentChecked = Math.round((itemsChecked/checklistItems.length)*100);
|
||||
return this.template({
|
||||
checklistIndex : index,
|
||||
checklistShortDescription : checklist.attributes['short_description'],
|
||||
items: checklistItems,
|
||||
itemsChecked: itemsChecked,
|
||||
percentChecked: percentChecked});
|
||||
},
|
||||
|
||||
toggleChecklist : function(e) {
|
||||
e.preventDefault();
|
||||
$(e.target).closest('.course-checklist').toggleClass('is-collapsed');
|
||||
},
|
||||
|
||||
toggleTask : function (e) {
|
||||
var self = this;
|
||||
|
||||
var completed = 'is-completed';
|
||||
var $checkbox = $(e.target);
|
||||
var $task = $checkbox.closest('.task');
|
||||
$task.toggleClass(completed);
|
||||
|
||||
var checklist_index = $checkbox.data('checklist');
|
||||
var task_index = $checkbox.data('task');
|
||||
var model = this.collection.at(checklist_index);
|
||||
model.attributes.items[task_index].is_checked = $task.hasClass(completed);
|
||||
|
||||
model.save({},
|
||||
{
|
||||
success : function() {
|
||||
var updatedTemplate = self.renderTemplate(model, checklist_index);
|
||||
self.$el.find('#course-checklist'+checklist_index).first().replaceWith(updatedTemplate);
|
||||
|
||||
analytics.track('Toggled a Checklist Task', {
|
||||
'course': course_location_analytics,
|
||||
'task': model.attributes.items[task_index].short_description,
|
||||
'state': model.attributes.items[task_index].is_checked
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
popup: function(e) {
|
||||
e.preventDefault();
|
||||
window.open($(e.target).attr('href'));
|
||||
}
|
||||
});
|
||||
return ChecklistView;
|
||||
});
|
||||
@@ -194,7 +194,7 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview',
|
||||
this.$('input.date').datepicker({'dateFormat': 'm/d/yy'});
|
||||
this.$('input.time').timepicker({
|
||||
'timeFormat' : 'H:i',
|
||||
'forceRoundTime': true
|
||||
'forceRoundTime': false
|
||||
});
|
||||
if (this.model.get(this.fieldName)) {
|
||||
DateUtils.setDate(
|
||||
|
||||
@@ -11,6 +11,14 @@ define(["jquery", "gettext", "common/js/components/utils/view_utils", "js/views/
|
||||
|
||||
CreateUtilsFactory.call(this, selectors, classes, keyLengthViolationMessage, keyFieldSelectors, nonEmptyCheckFieldSelectors);
|
||||
|
||||
this.setupOrgAutocomplete = function(){
|
||||
$.getJSON('/organizations', function (data) {
|
||||
$(selectors.org).autocomplete({
|
||||
source: data
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
this.create = function (courseInfo, errorHandler) {
|
||||
$.postJSON(
|
||||
'/course/',
|
||||
|
||||
@@ -61,7 +61,6 @@
|
||||
@import 'views/unit';
|
||||
@import 'views/container';
|
||||
@import 'views/users';
|
||||
@import 'views/checklists';
|
||||
@import 'views/textbooks';
|
||||
@import 'views/export-git';
|
||||
@import 'views/group-configuration';
|
||||
|
||||
@@ -368,12 +368,10 @@ body.course.view-certificates .nav-course-settings-certificates,
|
||||
// course tools
|
||||
body.course.view-import .nav-course-tools .title,
|
||||
body.course.view-export .nav-course-tools .title,
|
||||
body.course.view-checklists .nav-course-tools .title,
|
||||
body.course.view-export-git .nav-course-tools .title,
|
||||
|
||||
body.course.view-import .nav-course-tools-import,
|
||||
body.course.view-export .nav-course-tools-export,
|
||||
body.course.view-checklists .nav-course-tools-checklists,
|
||||
body.course.view-export-git .nav-course-tools-export-git,
|
||||
|
||||
// content library settings
|
||||
|
||||
@@ -157,3 +157,35 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ui-autocomplete {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
cursor: default;
|
||||
@include linear-gradient($gray-l5, $white);
|
||||
border-right: 1px solid $gray-l2;
|
||||
border-bottom: 1px solid $gray-l2;
|
||||
border-left: 1px solid $gray-l2;
|
||||
background-color: $gray-l5;
|
||||
box-shadow: inset 0 1px 2px $shadow-l1;
|
||||
color: $color-copy-emphasized;
|
||||
|
||||
li.ui-menu-item{
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
a {
|
||||
color: $color-copy-emphasized;
|
||||
}
|
||||
|
||||
a.ui-state-focus{
|
||||
border: none;
|
||||
background-color: $blue;
|
||||
background: $blue;
|
||||
color: $white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,341 +0,0 @@
|
||||
// Studio - Course Settings
|
||||
// ====================
|
||||
|
||||
.view-checklists {
|
||||
|
||||
.content-primary, .content-supplementary {
|
||||
@include box-sizing(border-box);
|
||||
}
|
||||
|
||||
.content-primary {
|
||||
@extend .ui-col-wide;
|
||||
}
|
||||
|
||||
// checklists - general
|
||||
.course-checklist {
|
||||
@extend %ui-window;
|
||||
margin: 0 0 ($baseline*2) 0;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
// visual status
|
||||
.viz-checklist-status {
|
||||
@extend %cont-text-hide;
|
||||
@include size(100%,($baseline/4));
|
||||
position: relative;
|
||||
display: block;
|
||||
margin: 0;
|
||||
background: $gray-l4;
|
||||
|
||||
.viz-checklist-status-value {
|
||||
@include transition(width $tmg-s2 ease-in-out .25s);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 0%;
|
||||
height: ($baseline/4);
|
||||
background: $green;
|
||||
|
||||
.int {
|
||||
@extend %cont-text-sr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// header/title
|
||||
header {
|
||||
@include clearfix();
|
||||
box-shadow: inset 0 -1px 1px $shadow-l1;
|
||||
margin-bottom: 0;
|
||||
border-bottom: 1px solid $gray-l3;
|
||||
padding: $baseline ($baseline*1.5);
|
||||
|
||||
.checklist-title {
|
||||
@include transition(color $tmg-f2 ease-in-out 0s);
|
||||
width: flex-grid(6, 9);
|
||||
margin: 0;
|
||||
@include margin-right(flex-gutter());
|
||||
@include float(left);
|
||||
|
||||
.ui-toggle-expansion {
|
||||
@include transition(all $tmg-f2 ease-in-out 0s);
|
||||
@extend %t-action1;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
@include margin-right($baseline/2);
|
||||
color: $gray-l4;
|
||||
}
|
||||
|
||||
&.is-selectable {
|
||||
@extend %ui-fake-link;
|
||||
|
||||
&:hover {
|
||||
color: $blue;
|
||||
|
||||
.ui-toggle-expansion {
|
||||
color: $blue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.checklist-status {
|
||||
@extend %t-copy-sub1;
|
||||
width: flex-grid(3, 9);
|
||||
@include float(right);
|
||||
margin-top: ($baseline/2);
|
||||
@include text-align(right);
|
||||
color: $gray-l2;
|
||||
|
||||
|
||||
.fa-check-square-o {
|
||||
@extend %t-icon4;
|
||||
display: inline-block;
|
||||
margin-left: ($baseline/2);
|
||||
color: $gray-l4;
|
||||
}
|
||||
|
||||
.status-count {
|
||||
@extend %t-copy-base;
|
||||
@extend %t-strong;
|
||||
margin-left: ($baseline/4);
|
||||
margin-right: ($baseline/4);
|
||||
color: $gray-d3;
|
||||
}
|
||||
|
||||
.status-amount {
|
||||
@extend %t-copy-base;
|
||||
@extend %t-strong;
|
||||
margin-left: ($baseline/4);
|
||||
color: $gray-d3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checklist actions
|
||||
.course-checklist-actions {
|
||||
@include clearfix();
|
||||
@include transition(border $tmg-f2 ease-in-out .25s);
|
||||
box-shadow: inset 0 1px 1px $shadow-l1;
|
||||
border-top: 1px solid $gray-l2;
|
||||
padding: $baseline ($baseline*1.5);
|
||||
background: $gray-l4;
|
||||
|
||||
.action-primary {
|
||||
@include green-button();
|
||||
@include float(left);
|
||||
|
||||
.fa-plus {
|
||||
@extend %t-icon7;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin-right: ($baseline/4);
|
||||
}
|
||||
}
|
||||
|
||||
.action-secondary {
|
||||
@include grey-button();
|
||||
@extend %t-action3;
|
||||
@extend %t-regular;
|
||||
@include float(right);
|
||||
|
||||
.fa-trash-o {
|
||||
@extend %t-icon7;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin-right: ($baseline/4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// state - collapsed
|
||||
&.is-collapsed {
|
||||
|
||||
header {
|
||||
box-shadow: none;
|
||||
|
||||
.checklist-title {
|
||||
|
||||
.ui-toggle-expansion {
|
||||
@include transform(rotate(-90deg));
|
||||
@include transform-origin(50% 50%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.list-tasks {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// state - completed
|
||||
&.is-completed {
|
||||
|
||||
.viz-checklist-status {
|
||||
|
||||
.viz-checklist-status-value {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
header {
|
||||
|
||||
.checklist-title, .fa-caret-down {
|
||||
color: $green;
|
||||
}
|
||||
|
||||
.checklist-status {
|
||||
|
||||
.status-count, .status-amount, .fa-check-square-o {
|
||||
color: $green;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// state - not available
|
||||
.is-not-available {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// list of tasks
|
||||
.list-tasks {
|
||||
height: auto;
|
||||
overflow: hidden;
|
||||
|
||||
.task {
|
||||
@include transition(background $tmg-f2 ease-in-out 0s, border $tmg-f3 ease-in-out 0s);
|
||||
@include clearfix();
|
||||
position: relative;
|
||||
border-top: 1px solid $white;
|
||||
border-bottom: 1px solid $gray-l5;
|
||||
padding: $baseline ($baseline*1.5);
|
||||
background: $white;
|
||||
opacity: 1.0;
|
||||
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.task-input {
|
||||
display: inline-block;
|
||||
vertical-align: text-top;
|
||||
@include float(left);
|
||||
margin-top: ($baseline/2);
|
||||
@include margin-right(flex-gutter());
|
||||
}
|
||||
|
||||
.task-details {
|
||||
@extend %t-strong;
|
||||
display: inline-block;
|
||||
vertical-align: text-top;
|
||||
@include float(left);
|
||||
width: flex-grid(6,9);
|
||||
|
||||
.task-name {
|
||||
@include transition(color $tmg-f2 ease-in-out 0s);
|
||||
@extend %ui-fake-link;
|
||||
vertical-align: baseline;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.task-description {
|
||||
@extend %t-copy-sub1;
|
||||
@include transition(color $tmg-f2 ease-in-out 0s);
|
||||
color: $gray-l2;
|
||||
}
|
||||
|
||||
.task-support {
|
||||
@extend %t-copy-sub2;
|
||||
@include transition(opacity $tmg-f2 ease-in-out 0s);
|
||||
opacity: 0.0;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.task-actions {
|
||||
@include transition(opacity $tmg-f2 ease-in-out $tmg-f2);
|
||||
@include clearfix();
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
@include float(right);
|
||||
width: flex-grid(2,9);
|
||||
margin: ($baseline/2) 0 0 flex-gutter();
|
||||
opacity: 0.0;
|
||||
pointer-events: none;
|
||||
text-align: right;
|
||||
|
||||
.action-primary {
|
||||
@extend %btn-primary-blue;
|
||||
@extend %t-action3;
|
||||
}
|
||||
|
||||
.action-secondary {
|
||||
@extend %t-action4;
|
||||
margin-top: ($baseline/2);
|
||||
}
|
||||
}
|
||||
|
||||
// state - hover
|
||||
&:hover {
|
||||
background: $blue-l5;
|
||||
border-bottom-color: $blue-l4;
|
||||
border-top-color: $blue-l4;
|
||||
opacity: 1.0;
|
||||
|
||||
.task-details {
|
||||
|
||||
.task-support {
|
||||
opacity: 1.0;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.task-actions {
|
||||
opacity: 1.0;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// state - completed
|
||||
&.is-completed {
|
||||
background: $gray-l6;
|
||||
border-top-color: $gray-l5;
|
||||
border-bottom-color: $gray-l5;
|
||||
|
||||
.task-name {
|
||||
color: $gray-l2;
|
||||
}
|
||||
|
||||
.task-actions {
|
||||
|
||||
.action-primary {
|
||||
@extend %btn-secondary-blue;
|
||||
@extend %t-action3;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: $gray-l5;
|
||||
border-bottom-color: $gray-l4;
|
||||
border-top-color: $gray-l4;
|
||||
|
||||
.task-details {
|
||||
opacity:1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content-supplementary {
|
||||
@extend .ui-col-narrow;
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
<%inherit file="base.html" />
|
||||
<%def name="online_help_token()"><% return "checklist" %></%def>
|
||||
<%!
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.utils.translation import ugettext as _
|
||||
%>
|
||||
<%block name="title">${_("Course Checklists")}</%block>
|
||||
<%block name="bodyclass">is-signedin course view-checklists</%block>
|
||||
|
||||
<%namespace name='static' file='static_content.html'/>
|
||||
|
||||
<%block name="header_extras">
|
||||
% for template_name in ["checklist"]:
|
||||
<script type="text/template" id="${template_name}-tpl">
|
||||
<%static:include path="js/${template_name}.underscore" />
|
||||
</script>
|
||||
% endfor
|
||||
</%block>
|
||||
|
||||
<%block name="requirejs">
|
||||
require(["js/factories/checklists"], function (ChecklistsFactory) {
|
||||
ChecklistsFactory("${handler_url}");
|
||||
});
|
||||
</%block>
|
||||
|
||||
|
||||
<%block name="content">
|
||||
<div class="wrapper-mast wrapper">
|
||||
<header class="mast has-actions has-subtitle">
|
||||
<h1 class="page-header">
|
||||
<small class="subtitle">${_("Tools")}</small>
|
||||
<span class="sr">> </span>${_("Course Checklists")}
|
||||
</h1>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<div class="wrapper-content wrapper">
|
||||
<section class="content">
|
||||
<article class="content-primary" role="main">
|
||||
<form id="course-checklists" class="course-checklists" method="post" action="">
|
||||
<h2 class="title title-3 sr">${_("Current Checklists")}</h2>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
<aside class="content-supplementary" role="complementary">
|
||||
<div class="bit">
|
||||
<h3 class="title title-3">${_("What are course checklists?")}</h3>
|
||||
<p>
|
||||
${_("Course checklists are tools to help you understand and keep track of all the steps necessary to get your course ready for students.")}
|
||||
</p>
|
||||
<p>
|
||||
${_("Any changes you make to these checklists are saved automatically and are immediately visible to other course team members.")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bit">
|
||||
<h3 class="title title-3">${_("{studio_name} checklists").format(studio_name=settings.STUDIO_SHORT_NAME)}</h3>
|
||||
<nav class="nav-page checklists-current">
|
||||
<ol>
|
||||
% for checklist in checklists:
|
||||
<li class="nav-item">
|
||||
<a rel="view" href="${'#course-checklist' + str(loop.index)}">${checklist['short_description']}</a>
|
||||
</li>
|
||||
% endfor
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
</%block>
|
||||
@@ -79,7 +79,7 @@
|
||||
## Translators: This is an example for the name of the organization sponsoring a course, seen when filling out the form to create a new course. The organization name cannot contain spaces.
|
||||
## Translators: "e.g. UniversityX or OrganizationX" is a placeholder displayed when user put no data into this field.
|
||||
<input class="new-course-org" id="new-course-org" type="text" name="new-course-org" required placeholder="${_('e.g. UniversityX or OrganizationX')}" aria-describedby="tip-new-course-org tip-error-new-course-org" />
|
||||
<span class="tip" id="tip-new-course-org">${_("The name of the organization sponsoring the course.")} <strong>${_("Note: This is part of your course URL, so no spaces or special characters are allowed.")}</strong> ${_("This cannot be changed, but you can set a different display name in Advanced Settings later.")}</span>
|
||||
<span class="tip" id="tip-new-course-org">${_("The name of the organization sponsoring the course.")} <strong>${_("Note: The organization name is part of the course URL")}</strong> ${_("This cannot be changed, but you can set a different display name in Advanced Settings later.")}</span>
|
||||
<span class="tip tip-error is-hiding" id="tip-error-new-course-org"></span>
|
||||
</li>
|
||||
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
<% var allChecked = itemsChecked == items.length; %>
|
||||
<section
|
||||
<% if (allChecked) { %>
|
||||
class="course-checklist is-completed"
|
||||
<% } else { %>
|
||||
class="course-checklist"
|
||||
<% } %>
|
||||
id="<%= 'course-checklist' + checklistIndex %>">
|
||||
<span class="viz viz-checklist-status"><span class="viz value viz-checklist-status-value" style="width: <%= percentChecked %>%;">
|
||||
<%= _.template(
|
||||
// Translators: The {pct_sign} here represents the percent sign, i.e., '%'
|
||||
// in many languages. This is used to avoid Transifex's misinterpreting of
|
||||
// '% o'. The percent sign is also translatable as a standalone string.
|
||||
gettext("{number}{pct_sign} of checklists completed"),
|
||||
// Translators: This is the percent sign. It will be used to represent a
|
||||
// percent value out of 100, e.g. "58%" means "58/100".
|
||||
{number: '<span class="int">' + percentChecked + '</span>', pct_sign: gettext('%')},
|
||||
{interpolate: /\{(.+?)\}/g}
|
||||
)
|
||||
%>
|
||||
</span></span>
|
||||
<header>
|
||||
<h3 class="checklist-title title-2 is-selectable" title="Collapse/Expand this Checklist">
|
||||
<i class="icon fa fa-caret-down ui-toggle-expansion"></i>
|
||||
<%= checklistShortDescription %></h3>
|
||||
<span class="checklist-status status">
|
||||
<%= gettext("Tasks Completed:") %> <span class="status-count"><%= itemsChecked %></span>/<span class="status-amount"><%= items.length %></span>
|
||||
<i class="icon fa fa-check-square-o"></i>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<ul class="list list-tasks">
|
||||
<% var taskIndex = 0; %>
|
||||
<% _.each(items, function(item) { %>
|
||||
<% var checked = item['is_checked']; %>
|
||||
<li
|
||||
<% if (checked) { %>
|
||||
class="task is-completed"
|
||||
<% } else { %>
|
||||
class="task"
|
||||
<% } %>
|
||||
>
|
||||
<% var taskId = 'course-checklist' + checklistIndex + '-task' + taskIndex; %>
|
||||
<input type="checkbox" class="task-input" data-checklist="<%= checklistIndex %>" data-task="<%= taskIndex %>"
|
||||
name="<%= taskId %>" id="<%= taskId %>"
|
||||
<% if (checked) { %>
|
||||
checked="checked"
|
||||
<% } %>
|
||||
>
|
||||
<label class="task-details" for="<%= taskId %>">
|
||||
<h4 class="task-name title title-3"><%= item['short_description'] %></h4>
|
||||
<p class="task-description"><%= item['long_description'] %></p>
|
||||
</label>
|
||||
|
||||
<% if (item['action_text'] !== '' && item['action_url'] !== '') { %>
|
||||
<ul class="list-actions task-actions">
|
||||
<li class="action-item">
|
||||
<a href="<%= item['action_url'] %>" class="action action-primary"
|
||||
<% if (item['action_external']) { %>
|
||||
rel="external" title="<%= gettext("This link will open in a new browser window/tab") %>"
|
||||
<% } %>
|
||||
><%= item['action_text'] %></a>
|
||||
</li>
|
||||
</ul>
|
||||
<% } %>
|
||||
</li>
|
||||
|
||||
<% taskIndex+=1; }) %>
|
||||
|
||||
</ul>
|
||||
</section>
|
||||
@@ -12,5 +12,5 @@
|
||||
</%block>
|
||||
|
||||
<%block name="content">
|
||||
<div class="js-program-admin program-app layout-1q3q layout-reversed" data-api-url=${programs_api_url} data-home-url=${studio_home_url}></div>
|
||||
<div class="js-program-admin program-app layout-1q3q layout-reversed" data-api-url=${programs_api_url} data-auth-url=${programs_token_url} data-home-url=${studio_home_url}></div>
|
||||
</%block>
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
import json
|
||||
from contentstore import utils
|
||||
from django.utils.translation import ugettext as _
|
||||
from models.settings.encoder import CourseSettingsEncoder
|
||||
from openedx.core.lib.js_utils import escape_json_dumps
|
||||
from models.settings.course_details import CourseSettingsEncoder
|
||||
%>
|
||||
|
||||
<%block name="header_extras">
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
<%
|
||||
course_key = context_course.id
|
||||
index_url = reverse('contentstore.views.course_handler', kwargs={'course_key_string': unicode(course_key)})
|
||||
checklists_url = reverse('contentstore.views.checklists_handler', kwargs={'course_key_string': unicode(course_key)})
|
||||
course_team_url = reverse('contentstore.views.course_team_handler', kwargs={'course_key_string': unicode(course_key)})
|
||||
assets_url = reverse('contentstore.views.assets_handler', kwargs={'course_key_string': unicode(course_key)})
|
||||
textbooks_url = reverse('contentstore.views.textbooks_list_handler', kwargs={'course_key_string': unicode(course_key)})
|
||||
@@ -113,9 +112,6 @@
|
||||
<div class="wrapper wrapper-nav-sub">
|
||||
<div class="nav-sub">
|
||||
<ul>
|
||||
<li class="nav-item nav-course-tools-checklists">
|
||||
<a href="${checklists_url}">${_("Checklists")}</a>
|
||||
</li>
|
||||
<li class="nav-item nav-course-tools-import">
|
||||
<a href="${import_url}">${_("Import")}</a>
|
||||
</li>
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
<div class="wrapper-comp-editor" id="editor-tab">
|
||||
<section class="combinedopenended-editor editor">
|
||||
<div class="row">
|
||||
%if enable_markdown:
|
||||
<div class="editor-bar">
|
||||
<ul class="format-buttons">
|
||||
<li><a href="#" class="prompt-button" data-tooltip="Prompt"><span
|
||||
class="combinedopenended-editor-icon fa fa-quote-left"></span></a></li>
|
||||
<li><a href="#" class="rubric-button" data-tooltip="Rubric"><span
|
||||
class="combinedopenended-editor-icon fa fa-table"></span></a></li>
|
||||
<li><a href="#" class="tasks-button" data-tooltip="Tasks"><span
|
||||
class="combinedopenended-editor-icon fa fa-sitemap"></span></a></li>
|
||||
</ul>
|
||||
<ul class="editor-tabs">
|
||||
<li><a href="#" class="xml-tab advanced-toggle" data-tab="xml">Advanced Editor</a></li>
|
||||
<li><a href="#" class="cheatsheet-toggle" data-tooltip="Toggle Cheatsheet">?</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<textarea class="markdown-box">${markdown | h}</textarea>
|
||||
%endif
|
||||
<textarea class="xml-box" rows="8" cols="40">${data | h}</textarea>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script type="text/template" id="open-ended-template">
|
||||
<openended %min_max_string%>
|
||||
<openendedparam>
|
||||
<initial_display>Enter essay here.</initial_display>
|
||||
<answer_display>This is the answer.</answer_display>
|
||||
<grader_payload>{"grader_settings" : "%grading_config%", "problem_id" : "6.002x/Welcome/OETest"}</grader_payload>
|
||||
</openendedparam>
|
||||
</openended>
|
||||
</script>
|
||||
|
||||
<script type="text/template" id="simple-editor-open-ended-cheatsheet">
|
||||
<article class="simple-editor-open-ended-cheatsheet">
|
||||
<div class="cheatsheet-wrapper">
|
||||
<div class="row">
|
||||
<h6>Prompt</h6>
|
||||
<div class="col prompt">
|
||||
</div>
|
||||
<div class="col">
|
||||
<pre><code>
|
||||
[prompt]
|
||||
Why is the sky blue?
|
||||
[prompt]
|
||||
</code></pre>
|
||||
</div>
|
||||
<div class="col">
|
||||
<p>The student will respond to the prompt. The prompt can contain any html tags, such as paragraph tags and header tags.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<h6>Rubric</h6>
|
||||
<div class="col sample rubric"><!DOCTYPE html>
|
||||
</div>
|
||||
<div class="col">
|
||||
<pre><code>
|
||||
[rubric]
|
||||
+ Color Identification
|
||||
- Incorrect
|
||||
- Correct
|
||||
+ Grammar
|
||||
- Poor
|
||||
- Acceptable
|
||||
- Superb
|
||||
[rubric]
|
||||
</code></pre>
|
||||
</div>
|
||||
<div class="col">
|
||||
<p>The rubric is used for feedback and self-assessment. The rubric can have as many categories (+) and options (-) as desired. </p>
|
||||
<p>The total score for the problem will be the sum of all the points possible on the rubric. The options will be numbered sequentially from zero in each category, so each category will be worth as many points as its number of options minus one. </p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<h6>Tasks</h6>
|
||||
<div class="col sample tasks">
|
||||
</div>
|
||||
<div class="col">
|
||||
<pre><code>
|
||||
[tasks]
|
||||
(Self), ({1-3}AI), ({2-3}Peer)
|
||||
[tasks]
|
||||
</code></pre>
|
||||
</div>
|
||||
<div class="col">
|
||||
<p>The tasks define what feedback the student will get from the problem.</p>
|
||||
<p>Each task is defined with parentheses around it. Brackets (ie {2-3} above), specify the minimum and maximum score needed to attempt the given task.</p>
|
||||
<p>In the example above, the student will first be asked to self-assess. If they give themselves greater than or equal to a 1/3 and less than or equal to a 3/3 on the problem, then they will be moved to AI assessment. If they score themselves a 2/3 or 3/3 on AI assessment, they will move to peer assessment.</p>
|
||||
<p>Students will be given feedback from each task, and their final score for a given attempt of the problem will be their score last task that is completed.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</script>
|
||||
</div>
|
||||
<%include file="metadata-edit.html" />
|
||||
11
cms/urls.py
11
cms/urls.py
@@ -3,8 +3,8 @@ from django.conf.urls import patterns, include, url
|
||||
# There is a course creators admin table.
|
||||
from ratelimitbackend import admin
|
||||
|
||||
from cms.djangoapps.contentstore.views.program import ProgramAuthoringView
|
||||
|
||||
from cms.djangoapps.contentstore.views.program import ProgramAuthoringView, ProgramsIdTokenView
|
||||
from cms.djangoapps.contentstore.views.organization import OrganizationListView
|
||||
|
||||
admin.autodiscover()
|
||||
|
||||
@@ -41,6 +41,7 @@ urlpatterns = patterns(
|
||||
|
||||
url(r'^not_found$', 'contentstore.views.not_found', name='not_found'),
|
||||
url(r'^server_error$', 'contentstore.views.server_error', name='server_error'),
|
||||
url(r'^organizations$', OrganizationListView.as_view(), name='organizations'),
|
||||
|
||||
# temporary landing page for edge
|
||||
url(r'^edge$', 'contentstore.views.edge', name='edge'),
|
||||
@@ -92,7 +93,6 @@ urlpatterns += patterns(
|
||||
'course_notifications_handler'),
|
||||
url(r'^course_rerun/{}$'.format(settings.COURSE_KEY_PATTERN), 'course_rerun_handler', name='course_rerun_handler'),
|
||||
url(r'^container/{}$'.format(settings.USAGE_KEY_PATTERN), 'container_handler'),
|
||||
url(r'^checklists/{}/(?P<checklist_index>\d+)?$'.format(settings.COURSE_KEY_PATTERN), 'checklists_handler'),
|
||||
url(r'^orphan/{}$'.format(settings.COURSE_KEY_PATTERN), 'orphan_handler'),
|
||||
url(r'^assets/{}/{}?$'.format(settings.COURSE_KEY_PATTERN, settings.ASSET_KEY_PATTERN), 'assets_handler'),
|
||||
url(r'^import/{}$'.format(COURSELIKE_KEY_PATTERN), 'import_handler'),
|
||||
@@ -185,9 +185,10 @@ if settings.FEATURES.get('CERTIFICATES_HTML_VIEW'):
|
||||
)
|
||||
|
||||
urlpatterns += (
|
||||
# These views use a configuration model to determine whether or not to
|
||||
# display the Programs authoring app. If disabled, a 404 is returned.
|
||||
url(r'^programs/id_token/$', ProgramsIdTokenView.as_view(), name='programs_id_token'),
|
||||
# Drops into the Programs authoring app, which handles its own routing.
|
||||
# The view uses a configuration model to determine whether or not to
|
||||
# display the authoring app. If disabled, a 404 is returned.
|
||||
url(r'^program/', ProgramAuthoringView.as_view(), name='programs'),
|
||||
)
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ from opaque_keys import InvalidKeyError
|
||||
|
||||
from util.date_utils import get_time_display
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from course_modes.models import CourseMode
|
||||
from course_modes.models import CourseMode, CourseModeExpirationConfig
|
||||
|
||||
# Technically, we shouldn't be doing this, since verify_student is defined
|
||||
# in LMS, and course_modes is defined in common.
|
||||
@@ -66,12 +66,13 @@ class CourseModeForm(forms.ModelForm):
|
||||
|
||||
default_tz = timezone(settings.TIME_ZONE)
|
||||
|
||||
if self.instance.expiration_datetime:
|
||||
if self.instance._expiration_datetime: # pylint: disable=protected-access
|
||||
# django admin is using default timezone. To avoid time conversion from db to form
|
||||
# convert the UTC object to naive and then localize with default timezone.
|
||||
expiration_datetime = self.instance.expiration_datetime.replace(tzinfo=None)
|
||||
self.initial["expiration_datetime"] = default_tz.localize(expiration_datetime)
|
||||
|
||||
_expiration_datetime = self.instance._expiration_datetime.replace( # pylint: disable=protected-access
|
||||
tzinfo=None
|
||||
)
|
||||
self.initial["_expiration_datetime"] = default_tz.localize(_expiration_datetime)
|
||||
# Load the verification deadline
|
||||
# Since this is stored on a model in verify student, we need to load it from there.
|
||||
# We need to munge the timezone a bit to get Django admin to display it without converting
|
||||
@@ -99,14 +100,14 @@ class CourseModeForm(forms.ModelForm):
|
||||
|
||||
return course_key
|
||||
|
||||
def clean_expiration_datetime(self):
|
||||
def clean__expiration_datetime(self):
|
||||
"""
|
||||
Ensure that the expiration datetime we save uses the UTC timezone.
|
||||
"""
|
||||
# django admin saving the date with default timezone to avoid time conversion from form to db
|
||||
# changes its tzinfo to UTC
|
||||
if self.cleaned_data.get("expiration_datetime"):
|
||||
return self.cleaned_data.get("expiration_datetime").replace(tzinfo=UTC)
|
||||
if self.cleaned_data.get("_expiration_datetime"):
|
||||
return self.cleaned_data.get("_expiration_datetime").replace(tzinfo=UTC)
|
||||
|
||||
def clean_verification_deadline(self):
|
||||
"""
|
||||
@@ -122,7 +123,7 @@ class CourseModeForm(forms.ModelForm):
|
||||
"""
|
||||
cleaned_data = super(CourseModeForm, self).clean()
|
||||
mode_slug = cleaned_data.get("mode_slug")
|
||||
upgrade_deadline = cleaned_data.get("expiration_datetime")
|
||||
upgrade_deadline = cleaned_data.get("_expiration_datetime")
|
||||
verification_deadline = cleaned_data.get("verification_deadline")
|
||||
|
||||
# Allow upgrade deadlines ONLY for the "verified" mode
|
||||
@@ -181,7 +182,7 @@ class CourseModeAdmin(admin.ModelAdmin):
|
||||
'mode_display_name',
|
||||
'min_price',
|
||||
'currency',
|
||||
'expiration_datetime',
|
||||
'_expiration_datetime',
|
||||
'verification_deadline',
|
||||
'sku'
|
||||
)
|
||||
@@ -206,4 +207,12 @@ class CourseModeAdmin(admin.ModelAdmin):
|
||||
# in the Django admin list view.
|
||||
expiration_datetime_custom.short_description = "Upgrade Deadline"
|
||||
|
||||
|
||||
class CourseModeExpirationConfigAdmin(admin.ModelAdmin):
|
||||
"""Admin interface for the course mode auto expiration configuration. """
|
||||
|
||||
class Meta(object):
|
||||
model = CourseModeExpirationConfig
|
||||
|
||||
admin.site.register(CourseMode, CourseModeAdmin)
|
||||
admin.site.register(CourseModeExpirationConfig, CourseModeExpirationConfigAdmin)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('course_modes', '0002_coursemode_expiration_datetime_is_explicit'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='coursemode',
|
||||
name='expiration_datetime_is_explicit',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,32 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
from datetime import timedelta
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('course_modes', '0003_auto_20151113_1443'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='CourseModeExpirationConfig',
|
||||
fields=[
|
||||
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
|
||||
('change_date', models.DateTimeField(auto_now_add=True, verbose_name='Change date')),
|
||||
('enabled', models.BooleanField(default=False, verbose_name='Enabled')),
|
||||
('verification_window', models.DurationField(default=timedelta(10), help_text='The time period before a course ends in which a course mode will expire')),
|
||||
('changed_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, editable=False, to=settings.AUTH_USER_MODEL, null=True, verbose_name='Changed by')),
|
||||
],
|
||||
options={
|
||||
'ordering': ('-change_date',),
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,15 +1,15 @@
|
||||
"""
|
||||
Add and create new modes for running courses on this particular LMS
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
import pytz
|
||||
from datetime import datetime
|
||||
|
||||
from collections import namedtuple, defaultdict
|
||||
from config_models.models import ConfigurationModel
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from collections import namedtuple, defaultdict
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.db.models import Q
|
||||
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from xmodule_django.models import CourseKeyField
|
||||
|
||||
Mode = namedtuple('Mode',
|
||||
@@ -54,19 +54,20 @@ class CourseMode(models.Model):
|
||||
# For example, if there is a verified mode that expires on 1/1/2015,
|
||||
# then users will be able to upgrade into the verified mode before that date.
|
||||
# Once the date passes, users will no longer be able to enroll as verified.
|
||||
expiration_datetime = models.DateTimeField(
|
||||
_expiration_datetime = models.DateTimeField(
|
||||
default=None, null=True, blank=True,
|
||||
verbose_name=_(u"Upgrade Deadline"),
|
||||
help_text=_(
|
||||
u"OPTIONAL: After this date/time, users will no longer be able to enroll in this mode. "
|
||||
u"Leave this blank if users can enroll in this mode until enrollment closes for the course."
|
||||
),
|
||||
db_column='expiration_datetime',
|
||||
)
|
||||
|
||||
# The system prefers to set this automatically based on default settings. But
|
||||
# if the field is set manually we want a way to indicate that so we don't
|
||||
# overwrite the manual setting of the field.
|
||||
expiration_datetime_is_explicit = models.BooleanField(default=True)
|
||||
expiration_datetime_is_explicit = models.BooleanField(default=False)
|
||||
|
||||
# DEPRECATED: the `expiration_date` field has been replaced by `expiration_datetime`
|
||||
expiration_date = models.DateField(default=None, null=True, blank=True)
|
||||
@@ -150,6 +151,17 @@ class CourseMode(models.Model):
|
||||
"""
|
||||
return self.mode_slug
|
||||
|
||||
@property
|
||||
def expiration_datetime(self):
|
||||
""" Return _expiration_datetime. """
|
||||
return self._expiration_datetime
|
||||
|
||||
@expiration_datetime.setter
|
||||
def expiration_datetime(self, new_datetime):
|
||||
""" Saves datetime to _expiration_datetime and sets the explicit flag. """
|
||||
self.expiration_datetime_is_explicit = True
|
||||
self._expiration_datetime = new_datetime
|
||||
|
||||
@classmethod
|
||||
def all_modes_for_courses(cls, course_id_list):
|
||||
"""Find all modes for a list of course IDs, including expired modes.
|
||||
@@ -223,8 +235,8 @@ class CourseMode(models.Model):
|
||||
Q(course_id=course_id) &
|
||||
Q(min_price__gt=0) &
|
||||
(
|
||||
Q(expiration_datetime__isnull=True) |
|
||||
Q(expiration_datetime__gte=now)
|
||||
Q(_expiration_datetime__isnull=True) |
|
||||
Q(_expiration_datetime__gte=now)
|
||||
)
|
||||
)
|
||||
return [mode.to_tuple() for mode in found_course_modes]
|
||||
@@ -259,7 +271,7 @@ class CourseMode(models.Model):
|
||||
# Filter out expired course modes if include_expired is not set
|
||||
if not include_expired:
|
||||
found_course_modes = found_course_modes.filter(
|
||||
Q(expiration_datetime__isnull=True) | Q(expiration_datetime__gte=now)
|
||||
Q(_expiration_datetime__isnull=True) | Q(_expiration_datetime__gte=now)
|
||||
)
|
||||
|
||||
# Credit course modes are currently not shown on the track selection page;
|
||||
@@ -633,3 +645,19 @@ class CourseModesArchive(models.Model):
|
||||
expiration_date = models.DateField(default=None, null=True, blank=True)
|
||||
|
||||
expiration_datetime = models.DateTimeField(default=None, null=True, blank=True)
|
||||
|
||||
|
||||
class CourseModeExpirationConfig(ConfigurationModel):
|
||||
"""
|
||||
Configuration for time period from end of course to auto-expire a course mode.
|
||||
"""
|
||||
verification_window = models.DurationField(
|
||||
default=timedelta(days=10),
|
||||
help_text=_(
|
||||
"The time period before a course ends in which a course mode will expire"
|
||||
)
|
||||
)
|
||||
|
||||
def __unicode__(self):
|
||||
""" Returns the unicode date of the verification window. """
|
||||
return unicode(self.verification_window)
|
||||
|
||||
36
common/djangoapps/course_modes/signals.py
Normal file
36
common/djangoapps/course_modes/signals.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""
|
||||
Signal handler for setting default course mode expiration dates
|
||||
"""
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.dispatch.dispatcher import receiver
|
||||
from xmodule.modulestore.django import SignalHandler, modulestore
|
||||
|
||||
from .models import CourseMode, CourseModeExpirationConfig
|
||||
|
||||
|
||||
@receiver(SignalHandler.course_published)
|
||||
def _listen_for_course_publish(sender, course_key, **kwargs): # pylint: disable=unused-argument
|
||||
"""
|
||||
Catches the signal that a course has been published in Studio and
|
||||
sets the verified mode dates to defaults.
|
||||
"""
|
||||
try:
|
||||
verified_mode = CourseMode.objects.get(course_id=course_key, mode_slug=CourseMode.VERIFIED)
|
||||
if _should_update_date(verified_mode):
|
||||
course = modulestore().get_course(course_key)
|
||||
if not course:
|
||||
return None
|
||||
verification_window = CourseModeExpirationConfig.current().verification_window
|
||||
new_expiration_datetime = course.end - verification_window
|
||||
|
||||
if verified_mode.expiration_datetime != new_expiration_datetime:
|
||||
# Set the expiration_datetime without triggering the explicit flag
|
||||
verified_mode._expiration_datetime = new_expiration_datetime # pylint: disable=protected-access
|
||||
verified_mode.save()
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
|
||||
|
||||
def _should_update_date(verified_mode):
|
||||
""" Returns whether or not the verified mode should be updated. """
|
||||
return not(verified_mode is None or verified_mode.expiration_datetime_is_explicit)
|
||||
4
common/djangoapps/course_modes/startup.py
Normal file
4
common/djangoapps/course_modes/startup.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""
|
||||
Setup the signals on startup.
|
||||
"""
|
||||
import course_modes.signals # pylint: disable=unused-import
|
||||
@@ -48,8 +48,8 @@ class AdminCourseModePageTest(ModuleStoreTestCase):
|
||||
'mode_display_name': 'verified',
|
||||
'min_price': 10,
|
||||
'currency': 'usd',
|
||||
'expiration_datetime_0': expiration.date(), # due to django admin datetime widget passing as seperate vals
|
||||
'expiration_datetime_1': expiration.time(),
|
||||
'_expiration_datetime_0': expiration.date(), # due to django admin datetime widget passing as separate vals
|
||||
'_expiration_datetime_1': expiration.time(),
|
||||
|
||||
}
|
||||
|
||||
@@ -201,7 +201,7 @@ class AdminCourseModeFormTest(ModuleStoreTestCase):
|
||||
"course_id": unicode(self.course.id),
|
||||
"mode_slug": mode,
|
||||
"mode_display_name": mode,
|
||||
"expiration_datetime": upgrade_deadline,
|
||||
"_expiration_datetime": upgrade_deadline,
|
||||
"currency": "usd",
|
||||
"min_price": 10,
|
||||
}, instance=course_mode)
|
||||
|
||||
@@ -49,7 +49,7 @@ class CourseModeModelTest(TestCase):
|
||||
min_price=min_price,
|
||||
suggested_prices=suggested_prices,
|
||||
currency=currency,
|
||||
expiration_datetime=expiration_datetime,
|
||||
_expiration_datetime=expiration_datetime,
|
||||
)
|
||||
|
||||
def test_save(self):
|
||||
@@ -403,3 +403,21 @@ class CourseModeModelTest(TestCase):
|
||||
return dict(zip(dict_keys, display_values.get('verify_none')))
|
||||
else:
|
||||
return dict(zip(dict_keys, display_values.get(dict_type)))
|
||||
|
||||
def test_expiration_datetime_explicitly_set(self):
|
||||
""" Verify that setting the expiration_date property sets the explicit flag. """
|
||||
verified_mode, __ = self.create_mode('verified', 'Verified Certificate')
|
||||
now = datetime.now()
|
||||
verified_mode.expiration_datetime = now
|
||||
|
||||
self.assertTrue(verified_mode.expiration_datetime_is_explicit)
|
||||
self.assertEqual(verified_mode.expiration_datetime, now)
|
||||
|
||||
def test_expiration_datetime_not_explicitly_set(self):
|
||||
""" Verify that setting the _expiration_date property does not set the explicit flag. """
|
||||
verified_mode, __ = self.create_mode('verified', 'Verified Certificate')
|
||||
now = datetime.now()
|
||||
verified_mode._expiration_datetime = now # pylint: disable=protected-access
|
||||
|
||||
self.assertFalse(verified_mode.expiration_datetime_is_explicit)
|
||||
self.assertEqual(verified_mode.expiration_datetime, now)
|
||||
|
||||
89
common/djangoapps/course_modes/tests/test_signals.py
Normal file
89
common/djangoapps/course_modes/tests/test_signals.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
Unit tests for the course_mode signals
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from mock import patch
|
||||
|
||||
import ddt
|
||||
from pytz import UTC
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
|
||||
from course_modes.models import CourseMode
|
||||
from course_modes.signals import _listen_for_course_publish
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class CourseModeSignalTest(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests for the course_mode course_published signal.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(CourseModeSignalTest, self).setUp()
|
||||
self.end = datetime.now(tz=UTC).replace(microsecond=0) + timedelta(days=7)
|
||||
self.course = CourseFactory.create(end=self.end)
|
||||
CourseMode.objects.all().delete()
|
||||
|
||||
def create_mode(
|
||||
self,
|
||||
mode_slug,
|
||||
mode_name,
|
||||
min_price=0,
|
||||
suggested_prices='',
|
||||
currency='usd',
|
||||
expiration_datetime=None,
|
||||
):
|
||||
"""
|
||||
Create a new course mode
|
||||
"""
|
||||
return CourseMode.objects.get_or_create(
|
||||
course_id=self.course.id,
|
||||
mode_display_name=mode_name,
|
||||
mode_slug=mode_slug,
|
||||
min_price=min_price,
|
||||
suggested_prices=suggested_prices,
|
||||
currency=currency,
|
||||
_expiration_datetime=expiration_datetime,
|
||||
)
|
||||
|
||||
def test_no_verified_mode(self):
|
||||
""" Verify expiration not updated by signal for non-verified mode. """
|
||||
course_mode, __ = self.create_mode('honor', 'honor')
|
||||
|
||||
_listen_for_course_publish('store', self.course.id)
|
||||
course_mode.refresh_from_db()
|
||||
|
||||
self.assertIsNone(course_mode.expiration_datetime)
|
||||
|
||||
@ddt.data(1, 14, 30)
|
||||
def test_verified_mode(self, verification_window):
|
||||
""" Verify signal updates expiration to configured time period before course end for verified mode. """
|
||||
course_mode, __ = self.create_mode('verified', 'verified')
|
||||
self.assertIsNone(course_mode.expiration_datetime)
|
||||
|
||||
with patch('course_modes.models.CourseModeExpirationConfig.current') as config:
|
||||
instance = config.return_value
|
||||
instance.verification_window = timedelta(days=verification_window)
|
||||
|
||||
_listen_for_course_publish('store', self.course.id)
|
||||
course_mode.refresh_from_db()
|
||||
|
||||
self.assertEqual(course_mode.expiration_datetime, self.end - timedelta(days=verification_window))
|
||||
|
||||
@ddt.data(1, 14, 30)
|
||||
def test_verified_mode_explicitly_set(self, verification_window):
|
||||
""" Verify signal does not update expiration for verified mode with explicitly set expiration. """
|
||||
course_mode, __ = self.create_mode('verified', 'verified')
|
||||
course_mode.expiration_datetime_is_explicit = True
|
||||
self.assertIsNone(course_mode.expiration_datetime)
|
||||
|
||||
with patch('course_modes.models.CourseModeExpirationConfig.current') as config:
|
||||
instance = config.return_value
|
||||
instance.verification_window = timedelta(days=verification_window)
|
||||
|
||||
_listen_for_course_publish('store', self.course.id)
|
||||
course_mode.refresh_from_db()
|
||||
|
||||
self.assertEqual(course_mode.expiration_datetime, self.end - timedelta(days=verification_window))
|
||||
@@ -150,10 +150,9 @@ class EnrollmentTest(EnrollmentTestMixin, ModuleStoreTestCase, APITestCase):
|
||||
throttle = EnrollmentUserThrottle()
|
||||
self.rate_limit, rate_duration = throttle.parse_rate(throttle.rate)
|
||||
|
||||
self.course = CourseFactory.create()
|
||||
# Load a CourseOverview. This initial load should result in a cache
|
||||
# miss; the modulestore is queried and course metadata is cached.
|
||||
__ = CourseOverview.get_from_id(self.course.id)
|
||||
# Pass emit_signals when creating the course so it would be cached
|
||||
# as a CourseOverview.
|
||||
self.course = CourseFactory.create(emit_signals=True)
|
||||
|
||||
self.user = UserFactory.create(
|
||||
username=self.USERNAME,
|
||||
@@ -336,7 +335,7 @@ class EnrollmentTest(EnrollmentTestMixin, ModuleStoreTestCase, APITestCase):
|
||||
requesting user.
|
||||
"""
|
||||
# Create another course, and enroll self.user in both courses.
|
||||
other_course = CourseFactory.create()
|
||||
other_course = CourseFactory.create(emit_signals=True)
|
||||
for course in self.course, other_course:
|
||||
CourseModeFactory.create(
|
||||
course_id=unicode(course.id),
|
||||
@@ -345,7 +344,7 @@ class EnrollmentTest(EnrollmentTestMixin, ModuleStoreTestCase, APITestCase):
|
||||
)
|
||||
self.assert_enrollment_status(
|
||||
course_id=unicode(course.id),
|
||||
max_mongo_calls=1,
|
||||
max_mongo_calls=0,
|
||||
)
|
||||
# Verify the user himself can see both of his enrollments.
|
||||
self._assert_enrollments_visible_in_list([self.course, other_course])
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('student', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='courseenrollment',
|
||||
name='mode',
|
||||
field=models.CharField(default=b'audit', max_length=100),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='historicalcourseenrollment',
|
||||
name='mode',
|
||||
field=models.CharField(default=b'audit', max_length=100),
|
||||
),
|
||||
]
|
||||
@@ -1,6 +1,7 @@
|
||||
from django.test import TestCase
|
||||
from django.test.client import Client
|
||||
from django.contrib.auth.models import User
|
||||
from django.conf import settings
|
||||
from django_comment_common.models import (
|
||||
Role, FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_STUDENT)
|
||||
from django_comment_common.utils import seed_permissions_roles
|
||||
@@ -175,7 +176,47 @@ class AutoAuthEnabledTestCase(UrlResetMixin, TestCase):
|
||||
response_data
|
||||
)
|
||||
|
||||
def _auto_auth(self, params=None, **kwargs):
|
||||
@ddt.data(*COURSE_IDS_DDT)
|
||||
@ddt.unpack
|
||||
def test_redirect_to_course(self, course_id, course_key):
|
||||
# Create a user and enroll in a course
|
||||
response = self._auto_auth({
|
||||
'username': 'test',
|
||||
'course_id': course_id,
|
||||
'redirect': True,
|
||||
'staff': 'true',
|
||||
}, status_code=302)
|
||||
|
||||
# Check that a course enrollment was created for the user
|
||||
self.assertEqual(CourseEnrollment.objects.count(), 1)
|
||||
enrollment = CourseEnrollment.objects.get(course_id=course_key)
|
||||
self.assertEqual(enrollment.user.username, "test")
|
||||
|
||||
# Check that the redirect was to the course info/outline page
|
||||
if settings.ROOT_URLCONF == 'lms.urls':
|
||||
url_pattern = '/info'
|
||||
else:
|
||||
url_pattern = '/course/{}'.format(unicode(course_key))
|
||||
|
||||
self.assertTrue(response.url.endswith(url_pattern)) # pylint: disable=no-member
|
||||
|
||||
def test_redirect_to_main(self):
|
||||
# Create user and redirect to 'home' (cms) or 'dashboard' (lms)
|
||||
response = self._auto_auth({
|
||||
'username': 'test',
|
||||
'redirect': True,
|
||||
'staff': 'true',
|
||||
}, status_code=302)
|
||||
|
||||
# Check that the redirect was to either /dashboard or /home
|
||||
if settings.ROOT_URLCONF == 'lms.urls':
|
||||
url_pattern = '/dashboard'
|
||||
else:
|
||||
url_pattern = '/home'
|
||||
|
||||
self.assertTrue(response.url.endswith(url_pattern)) # pylint: disable=no-member
|
||||
|
||||
def _auto_auth(self, params=None, status_code=None, **kwargs):
|
||||
"""
|
||||
Make a request to the auto-auth end-point and check
|
||||
that the response is successful.
|
||||
@@ -189,7 +230,9 @@ class AutoAuthEnabledTestCase(UrlResetMixin, TestCase):
|
||||
"""
|
||||
params = params or {}
|
||||
response = self.client.get(self.url, params, **kwargs)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
expected_status_code = status_code if status_code else 200
|
||||
self.assertEqual(response.status_code, expected_status_code)
|
||||
|
||||
# Check that session and CSRF are set in the response
|
||||
for cookie in ['csrftoken', 'sessionid']:
|
||||
|
||||
@@ -82,12 +82,6 @@ class CertificateDisplayTest(ModuleStoreTestCase):
|
||||
@override_settings(CERT_NAME_SHORT='Test_Certificate')
|
||||
@patch.dict('django.conf.settings.FEATURES', {'CERTIFICATES_HTML_VIEW': True})
|
||||
def test_linked_student_to_web_view_credential(self, enrollment_mode):
|
||||
test_url = get_certificate_url(
|
||||
user_id=self.user.id,
|
||||
course_id=unicode(self.course.id)
|
||||
)
|
||||
|
||||
self._create_certificate(enrollment_mode)
|
||||
certificates = [
|
||||
{
|
||||
'id': 0,
|
||||
@@ -103,6 +97,9 @@ class CertificateDisplayTest(ModuleStoreTestCase):
|
||||
self.course.save() # pylint: disable=no-member
|
||||
self.store.update_item(self.course, self.user.id)
|
||||
|
||||
cert = self._create_certificate(enrollment_mode)
|
||||
test_url = get_certificate_url(course_id=self.course.id, uuid=cert.verify_uuid)
|
||||
|
||||
response = self.client.get(reverse('dashboard'))
|
||||
|
||||
self.assertContains(response, u'View Test_Certificate')
|
||||
@@ -165,7 +162,7 @@ class CertificateDisplayTest(ModuleStoreTestCase):
|
||||
def _create_certificate(self, enrollment_mode):
|
||||
"""Simulate that the user has a generated certificate. """
|
||||
CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id, mode=enrollment_mode)
|
||||
GeneratedCertificateFactory(
|
||||
return GeneratedCertificateFactory(
|
||||
user=self.user,
|
||||
course_id=self.course.id,
|
||||
mode=enrollment_mode,
|
||||
|
||||
@@ -116,9 +116,8 @@ class CreditCourseDashboardTest(ModuleStoreTestCase):
|
||||
self._make_eligible()
|
||||
self._purchase_credit()
|
||||
|
||||
# Expect that the user's status is "pending"
|
||||
response = self._load_dashboard()
|
||||
self.assertContains(response, "credit-request-pending-msg")
|
||||
self.assertContains(response, "credit-request-not-started-msg")
|
||||
|
||||
def test_purchased_credit_and_request_pending(self):
|
||||
# Simulate that the user has purchased credit and initiated a request,
|
||||
|
||||
@@ -21,7 +21,7 @@ from django.contrib.auth.views import password_reset_confirm
|
||||
from django.contrib import messages
|
||||
from django.core.context_processors import csrf
|
||||
from django.core import mail
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.core.urlresolvers import reverse, NoReverseMatch
|
||||
from django.core.validators import validate_email, ValidationError
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.http import (HttpResponse, HttpResponseBadRequest, HttpResponseForbidden,
|
||||
@@ -338,13 +338,9 @@ def _cert_info(user, course_overview, cert_status, course_mode): # pylint: disa
|
||||
# showing the certificate web view button if certificate is ready state and feature flags are enabled.
|
||||
if has_html_certificates_enabled(course_overview.id, course_overview):
|
||||
if course_overview.has_any_active_web_certificate:
|
||||
certificate_url = get_certificate_url(
|
||||
user_id=user.id,
|
||||
course_id=unicode(course_overview.id),
|
||||
)
|
||||
status_dict.update({
|
||||
'show_cert_web_view': True,
|
||||
'cert_web_view_url': u'{url}'.format(url=certificate_url)
|
||||
'cert_web_view_url': get_certificate_url(course_id=course_overview.id, uuid=cert_status['uuid'])
|
||||
})
|
||||
else:
|
||||
# don't show download certificate button if we don't have an active certificate for course
|
||||
@@ -1802,6 +1798,7 @@ def auto_auth(request):
|
||||
* `course_id`: Enroll the student in the course with `course_id`
|
||||
* `roles`: Comma-separated list of roles to grant the student in the course with `course_id`
|
||||
* `no_login`: Define this to create the user but not login
|
||||
* `redirect`: Set to "true" will redirect to course if course_id is defined, otherwise it will redirect to dashboard
|
||||
|
||||
If username, email, or password are not provided, use
|
||||
randomly generated credentials.
|
||||
@@ -1826,6 +1823,7 @@ def auto_auth(request):
|
||||
if course_id:
|
||||
course_key = CourseLocator.from_string(course_id)
|
||||
role_names = [v.strip() for v in request.GET.get('roles', '').split(',') if v.strip()]
|
||||
redirect_when_done = request.GET.get('redirect', '').lower() == 'true'
|
||||
login_when_done = 'no_login' not in request.GET
|
||||
|
||||
form = AccountCreationForm(
|
||||
@@ -1888,8 +1886,32 @@ def auto_auth(request):
|
||||
create_comments_service_user(user)
|
||||
|
||||
# Provide the user with a valid CSRF token
|
||||
# then return a 200 response
|
||||
if request.META.get('HTTP_ACCEPT') == 'application/json':
|
||||
# then return a 200 response unless redirect is true
|
||||
if redirect_when_done:
|
||||
# Redirect to course info page if course_id is known
|
||||
if course_id:
|
||||
try:
|
||||
# redirect to course info page in LMS
|
||||
redirect_url = reverse(
|
||||
'info',
|
||||
kwargs={'course_id': course_id}
|
||||
)
|
||||
except NoReverseMatch:
|
||||
# redirect to course outline page in Studio
|
||||
redirect_url = reverse(
|
||||
'course_handler',
|
||||
kwargs={'course_key_string': course_id}
|
||||
)
|
||||
else:
|
||||
try:
|
||||
# redirect to dashboard for LMS
|
||||
redirect_url = reverse('dashboard')
|
||||
except NoReverseMatch:
|
||||
# redirect to home for Studio
|
||||
redirect_url = reverse('home')
|
||||
|
||||
return redirect(redirect_url)
|
||||
elif request.META.get('HTTP_ACCEPT') == 'application/json':
|
||||
response = JsonResponse({
|
||||
'created_status': u"Logged in" if login_when_done else "Created",
|
||||
'username': username,
|
||||
|
||||
@@ -1,535 +0,0 @@
|
||||
"""
|
||||
Stub implementation of ORA service.
|
||||
|
||||
This is an extremely simple version of the service, with most
|
||||
business logic removed. In particular, the stub:
|
||||
|
||||
1) Provides an infinite number of peer and calibration essays,
|
||||
with dummy data.
|
||||
|
||||
2) Simulates a set number of pending submissions for each student;
|
||||
grades submitted by one student are not used for any other student.
|
||||
|
||||
3) Ignores the scores/feedback students submit.
|
||||
|
||||
4) Ignores problem location: an essay graded for *any* problem is graded
|
||||
for *every* problem.
|
||||
|
||||
Basically, the stub tracks only the *number* of peer/calibration essays
|
||||
submitted by each student.
|
||||
"""
|
||||
|
||||
import json
|
||||
import pkg_resources
|
||||
from .http import StubHttpRequestHandler, StubHttpService, require_params
|
||||
|
||||
|
||||
class StudentState(object):
|
||||
"""
|
||||
Store state about the student that the stub
|
||||
ORA implementation needs to keep track of.
|
||||
"""
|
||||
INITIAL_ESSAYS_AVAILABLE = 3
|
||||
NUM_ESSAYS_REQUIRED = 1
|
||||
NUM_CALIBRATION_REQUIRED = 1
|
||||
|
||||
def __init__(self):
|
||||
self.num_graded = 0
|
||||
self.num_calibrated = 0
|
||||
|
||||
def grade_peer_essay(self):
|
||||
self.num_graded += 1
|
||||
|
||||
def grade_calibration_essay(self):
|
||||
self.num_calibrated += 1
|
||||
|
||||
@property
|
||||
def num_pending(self):
|
||||
return max(self.INITIAL_ESSAYS_AVAILABLE - self.num_graded, 0)
|
||||
|
||||
@property
|
||||
def num_required(self):
|
||||
return max(self.NUM_ESSAYS_REQUIRED - self.num_graded, 0)
|
||||
|
||||
@property
|
||||
def is_calibrated(self):
|
||||
return self.num_calibrated >= self.NUM_CALIBRATION_REQUIRED
|
||||
|
||||
|
||||
class StubOraHandler(StubHttpRequestHandler):
|
||||
"""
|
||||
Handler for ORA requests.
|
||||
"""
|
||||
|
||||
GET_URL_HANDLERS = {
|
||||
'/peer_grading/get_next_submission': '_get_next_submission',
|
||||
'/peer_grading/is_student_calibrated': '_is_student_calibrated',
|
||||
'/peer_grading/show_calibration_essay': '_show_calibration_essay',
|
||||
'/peer_grading/get_notifications': '_get_notifications',
|
||||
'/peer_grading/get_data_for_location': '_get_data_for_location',
|
||||
'/peer_grading/get_problem_list': '_get_problem_list',
|
||||
}
|
||||
|
||||
POST_URL_HANDLERS = {
|
||||
'/peer_grading/save_grade': '_save_grade',
|
||||
'/peer_grading/save_calibration_essay': '_save_calibration_essay',
|
||||
|
||||
# Test-specific, used by the XQueue stub to register a new submission,
|
||||
# which we use to discover valid problem locations in the LMS
|
||||
'/test/register_submission': '_register_submission'
|
||||
}
|
||||
|
||||
def do_GET(self):
|
||||
"""
|
||||
Handle GET methods to the ORA API stub.
|
||||
"""
|
||||
self._send_handler_response('GET')
|
||||
|
||||
def do_POST(self):
|
||||
"""
|
||||
Handle POST methods to the ORA API stub.
|
||||
"""
|
||||
self._send_handler_response('POST')
|
||||
|
||||
def _send_handler_response(self, method):
|
||||
"""
|
||||
Delegate response to handler methods.
|
||||
If no handler defined, send a 404 response.
|
||||
"""
|
||||
# Choose the list of handlers based on the HTTP method
|
||||
if method == 'GET':
|
||||
handler_list = self.GET_URL_HANDLERS
|
||||
elif method == 'POST':
|
||||
handler_list = self.POST_URL_HANDLERS
|
||||
else:
|
||||
self.log_error('Unrecognized method "{method}"'.format(method=method))
|
||||
return
|
||||
|
||||
# Check the path (without querystring params) against our list of handlers
|
||||
handler_name = handler_list.get(self.path_only)
|
||||
|
||||
if handler_name is not None:
|
||||
handler = getattr(self, handler_name, None)
|
||||
else:
|
||||
handler = None
|
||||
|
||||
# Delegate to the handler to send a response
|
||||
if handler is not None:
|
||||
handler()
|
||||
|
||||
# If we don't have a handler for this URL and/or HTTP method,
|
||||
# respond with a 404. This is the same behavior as the ORA API.
|
||||
else:
|
||||
self.send_response(404)
|
||||
|
||||
@require_params('GET', 'student_id', 'problem_id')
|
||||
def _is_student_calibrated(self):
|
||||
"""
|
||||
Query whether the student has completed enough calibration
|
||||
essays to begin peer grading.
|
||||
|
||||
Method: GET
|
||||
|
||||
Params:
|
||||
- student_id
|
||||
- problem_id
|
||||
|
||||
Result (JSON):
|
||||
- success (bool)
|
||||
- total_calibrated_on_so_far (int)
|
||||
- calibrated (bool)
|
||||
"""
|
||||
student = self._student('GET')
|
||||
if student is None:
|
||||
self._error_response()
|
||||
|
||||
else:
|
||||
self._success_response({
|
||||
'total_calibrated_on_so_far': student.num_calibrated,
|
||||
'calibrated': student.is_calibrated
|
||||
})
|
||||
|
||||
@require_params('GET', 'student_id', 'problem_id')
|
||||
def _show_calibration_essay(self):
|
||||
"""
|
||||
Retrieve a calibration essay for the student to grade.
|
||||
|
||||
Method: GET
|
||||
|
||||
Params:
|
||||
- student_id
|
||||
- problem_id
|
||||
|
||||
Result (JSON):
|
||||
- success (bool)
|
||||
- submission_id (str)
|
||||
- submission_key (str)
|
||||
- student_response (str)
|
||||
- prompt (str)
|
||||
- rubric (str)
|
||||
- max_score (int)
|
||||
"""
|
||||
self._success_response({
|
||||
'submission_id': self.server.DUMMY_DATA['submission_id'],
|
||||
'submission_key': self.server.DUMMY_DATA['submission_key'],
|
||||
'student_response': self.server.DUMMY_DATA['student_response'],
|
||||
'prompt': self.server.DUMMY_DATA['prompt'],
|
||||
'rubric': self.server.DUMMY_DATA['rubric'],
|
||||
'max_score': self.server.DUMMY_DATA['max_score']
|
||||
})
|
||||
|
||||
@require_params('GET', 'student_id', 'course_id')
|
||||
def _get_notifications(self):
|
||||
"""
|
||||
Query counts of submitted, required, graded, and available peer essays
|
||||
for a particular student.
|
||||
|
||||
Method: GET
|
||||
|
||||
Params:
|
||||
- student_id
|
||||
- course_id
|
||||
|
||||
Result (JSON):
|
||||
- success (bool)
|
||||
- student_sub_count (int)
|
||||
- count_required (int)
|
||||
- count_graded (int)
|
||||
- count_available (int)
|
||||
"""
|
||||
student = self._student('GET')
|
||||
if student is None:
|
||||
self._error_response()
|
||||
|
||||
else:
|
||||
self._success_response({
|
||||
'student_sub_count': self.server.DUMMY_DATA['student_sub_count'],
|
||||
'count_required': student.num_required,
|
||||
'count_graded': student.num_graded,
|
||||
'count_available': student.num_pending
|
||||
})
|
||||
|
||||
@require_params('GET', 'student_id', 'location')
|
||||
def _get_data_for_location(self):
|
||||
"""
|
||||
Query counts of submitted, required, graded, and available peer essays
|
||||
for a problem location.
|
||||
|
||||
This will send an error response if the problem has not
|
||||
been registered at the given `location`. This allows us
|
||||
to ignore problems that are self- or ai-graded.
|
||||
|
||||
Method: GET
|
||||
|
||||
Params:
|
||||
- student_id
|
||||
- location
|
||||
|
||||
Result (JSON):
|
||||
- success (bool)
|
||||
- student_sub_count (int)
|
||||
- count_required (int)
|
||||
- count_graded (int)
|
||||
- count_available (int)
|
||||
"""
|
||||
student = self._student('GET')
|
||||
location = self.get_params.get('location')
|
||||
|
||||
# Do not return data if we're missing the student param
|
||||
# or the problem has not yet been registered.
|
||||
if student is None or location not in self.server.problems:
|
||||
self._error_response()
|
||||
|
||||
else:
|
||||
self._success_response({
|
||||
'student_sub_count': self.server.DUMMY_DATA['student_sub_count'],
|
||||
'count_required': student.num_required,
|
||||
'count_graded': student.num_graded,
|
||||
'count_available': student.num_pending
|
||||
})
|
||||
|
||||
@require_params('GET', 'grader_id', 'location')
|
||||
def _get_next_submission(self):
|
||||
"""
|
||||
Retrieve the next submission for the student to peer-grade.
|
||||
|
||||
Method: GET
|
||||
|
||||
Params:
|
||||
- grader_id
|
||||
- location
|
||||
|
||||
Result (JSON):
|
||||
- success (bool)
|
||||
- submission_id (str)
|
||||
- submission_key (str)
|
||||
- student_response (str)
|
||||
- prompt (str, HTML)
|
||||
- rubric (str, XML)
|
||||
- max_score (int)
|
||||
"""
|
||||
self._success_response({
|
||||
'submission_id': self.server.DUMMY_DATA['submission_id'],
|
||||
'submission_key': self.server.DUMMY_DATA['submission_key'],
|
||||
'student_response': self.server.DUMMY_DATA['student_response'],
|
||||
'prompt': self.server.DUMMY_DATA['prompt'],
|
||||
'rubric': self.server.DUMMY_DATA['rubric'],
|
||||
'max_score': self.server.DUMMY_DATA['max_score']
|
||||
})
|
||||
|
||||
@require_params('GET', 'course_id')
|
||||
def _get_problem_list(self):
|
||||
"""
|
||||
Retrieve the list of problems available for peer grading.
|
||||
|
||||
Method: GET
|
||||
|
||||
Params:
|
||||
- course_id
|
||||
|
||||
Result (JSON):
|
||||
- success (bool)
|
||||
- problem_list (list)
|
||||
|
||||
where `problem_list` is a list of dictionaries with keys:
|
||||
- location (str)
|
||||
- problem_name (str)
|
||||
- num_graded (int)
|
||||
- num_pending (int)
|
||||
- num_required (int)
|
||||
"""
|
||||
self._success_response({'problem_list': self.server.problem_list})
|
||||
|
||||
@require_params('POST', 'grader_id', 'location', 'submission_id', 'score', 'feedback', 'submission_key')
|
||||
def _save_grade(self):
|
||||
"""
|
||||
Save a score and feedback for an essay the student has graded.
|
||||
|
||||
Method: POST
|
||||
|
||||
Params:
|
||||
- grader_id
|
||||
- location
|
||||
- submission_id
|
||||
- score
|
||||
- feedback
|
||||
- submission_key
|
||||
|
||||
Result (JSON):
|
||||
- success (bool)
|
||||
"""
|
||||
student = self._student('POST', key='grader_id')
|
||||
if student is None:
|
||||
self._error_response()
|
||||
|
||||
else:
|
||||
# Update the number of essays the student has graded
|
||||
student.grade_peer_essay()
|
||||
return self._success_response({})
|
||||
|
||||
@require_params('POST', 'student_id', 'location', 'calibration_essay_id', 'score', 'feedback', 'submission_key')
|
||||
def _save_calibration_essay(self):
|
||||
"""
|
||||
Save a score and feedback for a calibration essay the student has graded.
|
||||
Returns the scores/feedback that the instructor gave for the essay.
|
||||
|
||||
Method: POST
|
||||
|
||||
Params:
|
||||
- student_id
|
||||
- location
|
||||
- calibration_essay_id
|
||||
- score
|
||||
- feedback
|
||||
- submission_key
|
||||
|
||||
Result (JSON):
|
||||
- success (bool)
|
||||
- message (str)
|
||||
- actual_score (int)
|
||||
- actual_rubric (str, XML)
|
||||
- actual_feedback (str)
|
||||
"""
|
||||
student = self._student('POST')
|
||||
if student is None:
|
||||
self._error_response()
|
||||
|
||||
else:
|
||||
|
||||
# Increment the student calibration count
|
||||
student.grade_calibration_essay()
|
||||
|
||||
self._success_response({
|
||||
'message': self.server.DUMMY_DATA['message'],
|
||||
'actual_score': self.server.DUMMY_DATA['actual_score'],
|
||||
'actual_rubric': self.server.DUMMY_DATA['actual_rubric'],
|
||||
'actual_feedback': self.server.DUMMY_DATA['actual_feedback']
|
||||
})
|
||||
|
||||
@require_params('POST', 'grader_payload')
|
||||
def _register_submission(self):
|
||||
"""
|
||||
Test-specific method to register a new submission.
|
||||
This is used by `get_problem_list` to return valid locations in the LMS courseware.
|
||||
In tests, this end-point gets called by the XQueue stub when it receives new submissions,
|
||||
much like ORA discovers locations when students submit peer-graded problems to the XQueue.
|
||||
|
||||
Since the LMS sends *all* open-ended problems to the XQueue (including self- and ai-graded),
|
||||
we have to ignore everything except peer-graded problems. We do so by looking
|
||||
for the text 'peer' in the problem's name. This is a little bit of a hack,
|
||||
but it makes the implementation much simpler.
|
||||
|
||||
Method: POST
|
||||
|
||||
Params:
|
||||
- grader_payload (JSON dict)
|
||||
|
||||
Result: Empty
|
||||
|
||||
The only keys we use in `grader_payload` are 'location' and 'problem_id'.
|
||||
"""
|
||||
# Since this is a required param, we know it is in the post dict
|
||||
try:
|
||||
payload = json.loads(self.post_dict['grader_payload'])
|
||||
|
||||
except ValueError:
|
||||
self.log_message(
|
||||
"Could not decode grader payload as JSON: '{0}'".format(
|
||||
self.post_dict['grader_payload']))
|
||||
self.send_response(400)
|
||||
|
||||
else:
|
||||
|
||||
location = payload.get('location')
|
||||
name = payload.get('problem_id')
|
||||
|
||||
if location is not None and name is not None:
|
||||
|
||||
if "peer" in name.lower():
|
||||
self.server.register_problem(location, name)
|
||||
self.send_response(200)
|
||||
|
||||
else:
|
||||
self.log_message(
|
||||
"Problem '{0}' does not have 'peer' in its name. Ignoring...".format(name)
|
||||
)
|
||||
self.send_response(200)
|
||||
else:
|
||||
self.log_message(
|
||||
"Grader payload should contain 'location' and 'problem_id' keys: {0}".format(payload)
|
||||
)
|
||||
self.send_response(400)
|
||||
|
||||
def _student(self, method, key='student_id'):
|
||||
"""
|
||||
Return the `StudentState` instance for the student ID given
|
||||
in the request parameters.
|
||||
|
||||
`method` is the HTTP request method (either "GET" or "POST")
|
||||
and `key` is the parameter key.
|
||||
"""
|
||||
if method == 'GET':
|
||||
student_id = self.get_params.get(key)
|
||||
elif method == 'POST':
|
||||
student_id = self.post_dict.get(key)
|
||||
else:
|
||||
self.log_error("Unrecognized method '{method}'".format(method=method))
|
||||
return None
|
||||
|
||||
if student_id is None:
|
||||
self.log_error("Could not get student ID from parameters")
|
||||
return None
|
||||
|
||||
return self.server.student_state(student_id)
|
||||
|
||||
def _success_response(self, response_dict):
|
||||
"""
|
||||
Send a success response.
|
||||
`response_dict` is a Python dictionary to JSON-encode.
|
||||
"""
|
||||
response_dict['success'] = True
|
||||
response_dict['version'] = 1
|
||||
self.send_response(
|
||||
200, content=json.dumps(response_dict),
|
||||
headers={'Content-type': 'application/json'}
|
||||
)
|
||||
|
||||
def _error_response(self):
|
||||
"""
|
||||
Send an error response.
|
||||
"""
|
||||
response_dict = {'success': False, 'version': 1}
|
||||
self.send_response(
|
||||
400, content=json.dumps(response_dict),
|
||||
headers={'Content-type': 'application/json'}
|
||||
)
|
||||
|
||||
|
||||
class StubOraService(StubHttpService):
|
||||
"""
|
||||
Stub ORA service.
|
||||
"""
|
||||
HANDLER_CLASS = StubOraHandler
|
||||
|
||||
DUMMY_DATA = {
|
||||
'submission_id': 1,
|
||||
'submission_key': 'test key',
|
||||
'student_response': 'Test response',
|
||||
'prompt': 'Test prompt',
|
||||
'rubric': pkg_resources.resource_string(__name__, "data/ora_rubric.xml"),
|
||||
'max_score': 2,
|
||||
'message': 'Successfully saved calibration record.',
|
||||
'actual_score': 2,
|
||||
'actual_rubric': pkg_resources.resource_string(__name__, "data/ora_graded_rubric.xml"),
|
||||
'actual_feedback': 'Great job!',
|
||||
'student_sub_count': 1,
|
||||
'problem_name': 'test problem',
|
||||
'problem_list_num_graded': 1,
|
||||
'problem_list_num_pending': 1,
|
||||
'problem_list_num_required': 0,
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""
|
||||
Initialize student submission state.
|
||||
"""
|
||||
super(StubOraService, self).__init__(*args, **kwargs)
|
||||
|
||||
# Create a dict to map student ID's to their state
|
||||
self._students = dict()
|
||||
|
||||
# By default, no problems are available for peer grading
|
||||
# You can add to this list using the `register_location` HTTP end-point
|
||||
# This is a dict mapping problem locations to problem names
|
||||
self.problems = dict()
|
||||
|
||||
def student_state(self, student_id):
|
||||
"""
|
||||
Return the `StudentState` (named tuple) for the student
|
||||
with ID `student_id`. The student state can be modified by the caller.
|
||||
"""
|
||||
# Create the student state if it does not already exist
|
||||
if student_id not in self._students:
|
||||
student = StudentState()
|
||||
self._students[student_id] = student
|
||||
|
||||
# Retrieve the student state
|
||||
return self._students[student_id]
|
||||
|
||||
@property
|
||||
def problem_list(self):
|
||||
"""
|
||||
Return a list of problems available for peer grading.
|
||||
"""
|
||||
return [{
|
||||
'location': location, 'problem_name': name,
|
||||
'num_graded': self.DUMMY_DATA['problem_list_num_graded'],
|
||||
'num_pending': self.DUMMY_DATA['problem_list_num_pending'],
|
||||
'num_required': self.DUMMY_DATA['problem_list_num_required']
|
||||
} for location, name in self.problems.items()]
|
||||
|
||||
def register_problem(self, location, name):
|
||||
"""
|
||||
Register a new problem with `location` and `name` for peer grading.
|
||||
"""
|
||||
self.problems[location] = name
|
||||
@@ -7,7 +7,6 @@ import logging
|
||||
from .comments import StubCommentsService
|
||||
from .xqueue import StubXQueueService
|
||||
from .youtube import StubYouTubeService
|
||||
from .ora import StubOraService
|
||||
from .lti import StubLtiService
|
||||
from .video_source import VideoSourceHttpService
|
||||
from .edxnotes import StubEdxNotesService
|
||||
@@ -19,7 +18,6 @@ USAGE = "USAGE: python -m stubs.start SERVICE_NAME PORT_NUM [CONFIG_KEY=CONFIG_V
|
||||
SERVICES = {
|
||||
'xqueue': StubXQueueService,
|
||||
'youtube': StubYouTubeService,
|
||||
'ora': StubOraService,
|
||||
'comments': StubCommentsService,
|
||||
'lti': StubLtiService,
|
||||
'video': VideoSourceHttpService,
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
"""
|
||||
Unit tests for stub ORA implementation.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import requests
|
||||
import json
|
||||
from ..ora import StubOraService, StudentState
|
||||
|
||||
|
||||
class StubOraServiceTest(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Start the stub server.
|
||||
"""
|
||||
super(StubOraServiceTest, self).setUp()
|
||||
self.server = StubOraService()
|
||||
self.addCleanup(self.server.shutdown)
|
||||
|
||||
def test_calibration(self):
|
||||
|
||||
# Ensure that we use the same student ID throughout
|
||||
student_id = '1234'
|
||||
|
||||
# Initially, student should not be calibrated
|
||||
response = requests.get(
|
||||
self._peer_url('is_student_calibrated'),
|
||||
params={'student_id': student_id, 'problem_id': '5678'}
|
||||
)
|
||||
self._assert_response(response, {
|
||||
'version': 1, 'success': True,
|
||||
'total_calibrated_on_so_far': 0,
|
||||
'calibrated': False
|
||||
})
|
||||
|
||||
# Retrieve a calibration essay
|
||||
response = requests.get(
|
||||
self._peer_url('show_calibration_essay'),
|
||||
params={'student_id': student_id, 'problem_id': '5678'}
|
||||
)
|
||||
self._assert_response(response, {
|
||||
'version': 1, 'success': True,
|
||||
'submission_id': self.server.DUMMY_DATA['submission_id'],
|
||||
'submission_key': self.server.DUMMY_DATA['submission_key'],
|
||||
'student_response': self.server.DUMMY_DATA['student_response'],
|
||||
'prompt': self.server.DUMMY_DATA['prompt'],
|
||||
'rubric': self.server.DUMMY_DATA['rubric'],
|
||||
'max_score': self.server.DUMMY_DATA['max_score']
|
||||
})
|
||||
|
||||
# Grade the calibration essay
|
||||
response = requests.post(
|
||||
self._peer_url('save_calibration_essay'),
|
||||
data={
|
||||
'student_id': student_id,
|
||||
'location': 'test location',
|
||||
'calibration_essay_id': 1,
|
||||
'score': 2,
|
||||
'submission_key': 'key',
|
||||
'feedback': 'Good job!'
|
||||
}
|
||||
)
|
||||
self._assert_response(response, {
|
||||
'version': 1, 'success': True,
|
||||
'message': self.server.DUMMY_DATA['message'],
|
||||
'actual_score': self.server.DUMMY_DATA['actual_score'],
|
||||
'actual_rubric': self.server.DUMMY_DATA['actual_rubric'],
|
||||
'actual_feedback': self.server.DUMMY_DATA['actual_feedback']
|
||||
})
|
||||
|
||||
# Now the student should be calibrated
|
||||
response = requests.get(
|
||||
self._peer_url('is_student_calibrated'),
|
||||
params={'student_id': student_id, 'problem_id': '5678'}
|
||||
)
|
||||
self._assert_response(response, {
|
||||
'version': 1, 'success': True,
|
||||
'total_calibrated_on_so_far': 1,
|
||||
'calibrated': True
|
||||
})
|
||||
|
||||
# But a student with a different ID should NOT be calibrated.
|
||||
response = requests.get(
|
||||
self._peer_url('is_student_calibrated'),
|
||||
params={'student_id': 'another', 'problem_id': '5678'}
|
||||
)
|
||||
self._assert_response(response, {
|
||||
'version': 1, 'success': True,
|
||||
'total_calibrated_on_so_far': 0,
|
||||
'calibrated': False
|
||||
})
|
||||
|
||||
def test_grade_peers(self):
|
||||
|
||||
# Ensure a consistent student ID
|
||||
student_id = '1234'
|
||||
|
||||
# Check initial number of submissions
|
||||
# Should be none graded and 1 required
|
||||
self._assert_num_graded(student_id, None, 0, 1)
|
||||
|
||||
# Register a problem that DOES have "peer" in the name
|
||||
self._register_problem('test_location', 'Peer Assessed Problem')
|
||||
|
||||
# Retrieve the next submission
|
||||
response = requests.get(
|
||||
self._peer_url('get_next_submission'),
|
||||
params={'grader_id': student_id, 'location': 'test_location'}
|
||||
)
|
||||
self._assert_response(response, {
|
||||
'version': 1, 'success': True,
|
||||
'submission_id': self.server.DUMMY_DATA['submission_id'],
|
||||
'submission_key': self.server.DUMMY_DATA['submission_key'],
|
||||
'student_response': self.server.DUMMY_DATA['student_response'],
|
||||
'prompt': self.server.DUMMY_DATA['prompt'],
|
||||
'rubric': self.server.DUMMY_DATA['rubric'],
|
||||
'max_score': self.server.DUMMY_DATA['max_score']
|
||||
})
|
||||
|
||||
# Grade the submission
|
||||
response = requests.post(
|
||||
self._peer_url('save_grade'),
|
||||
data={
|
||||
'location': 'test_location',
|
||||
'grader_id': student_id,
|
||||
'submission_id': 1,
|
||||
'score': 2,
|
||||
'feedback': 'Good job!',
|
||||
'submission_key': 'key'
|
||||
}
|
||||
)
|
||||
self._assert_response(response, {'version': 1, 'success': True})
|
||||
|
||||
# Check final number of submissions
|
||||
# Shoud be one graded and none required
|
||||
self._assert_num_graded(student_id, 'test_location', 1, 0)
|
||||
|
||||
# Grade the next submission the submission
|
||||
response = requests.post(
|
||||
self._peer_url('save_grade'),
|
||||
data={
|
||||
'location': 'test_location',
|
||||
'grader_id': student_id,
|
||||
'submission_id': 1,
|
||||
'score': 2,
|
||||
'feedback': 'Good job!',
|
||||
'submission_key': 'key'
|
||||
}
|
||||
)
|
||||
self._assert_response(response, {'version': 1, 'success': True})
|
||||
|
||||
# Check final number of submissions
|
||||
# Shoud be two graded and none required
|
||||
self._assert_num_graded(student_id, 'test_location', 2, 0)
|
||||
|
||||
def test_problem_list(self):
|
||||
|
||||
self._register_problem('test_location', 'Peer Grading Problem')
|
||||
|
||||
# The problem list returns dummy counts which are not updated
|
||||
# The location we use is ignored by the LMS, and we ignore it in the stub,
|
||||
# so we use a dummy value there too.
|
||||
response = requests.get(
|
||||
self._peer_url('get_problem_list'),
|
||||
params={'course_id': 'test course'}
|
||||
)
|
||||
|
||||
self._assert_response(response, {
|
||||
'version': 1, 'success': True,
|
||||
'problem_list': [{
|
||||
'location': 'test_location',
|
||||
'problem_name': 'Peer Grading Problem',
|
||||
'num_graded': self.server.DUMMY_DATA['problem_list_num_graded'],
|
||||
'num_pending': self.server.DUMMY_DATA['problem_list_num_pending'],
|
||||
'num_required': self.server.DUMMY_DATA['problem_list_num_required']
|
||||
}]
|
||||
})
|
||||
|
||||
def test_ignore_non_peer_problem(self):
|
||||
|
||||
# Register a problem that does NOT have "peer" in the name
|
||||
self._register_problem('test_location', 'Self Assessed Problem')
|
||||
|
||||
# Expect that the problem list is empty
|
||||
response = requests.get(
|
||||
self._peer_url('get_problem_list'),
|
||||
params={'course_id': 'test course'}
|
||||
)
|
||||
|
||||
self._assert_response(
|
||||
response,
|
||||
{'version': 1, 'success': True, 'problem_list': []}
|
||||
)
|
||||
|
||||
# Expect that no data is available for the problem location
|
||||
response = requests.get(
|
||||
self._peer_url('get_data_for_location'),
|
||||
params={'location': 'test_location', 'student_id': 'test'}
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json(), {'version': 1, 'success': False})
|
||||
|
||||
def test_empty_problem_list(self):
|
||||
|
||||
# Without configuring any problem location, should return an empty list
|
||||
response = requests.get(
|
||||
self._peer_url('get_problem_list'),
|
||||
params={'course_id': 'test course'}
|
||||
)
|
||||
self._assert_response(response, {'version': 1, 'success': True, 'problem_list': []})
|
||||
|
||||
def _peer_url(self, path):
|
||||
"""
|
||||
Construt a URL to the stub ORA peer-grading service.
|
||||
"""
|
||||
return "http://127.0.0.1:{port}/peer_grading/{path}/".format(
|
||||
port=self.server.port, path=path
|
||||
)
|
||||
|
||||
def _register_problem(self, location, name):
|
||||
"""
|
||||
Configure the stub to use a particular problem location
|
||||
The actual implementation discovers problem locations by submission
|
||||
to the XQueue; we do something similar by having the XQueue stub
|
||||
register submitted locations with the ORA stub.
|
||||
"""
|
||||
grader_payload = json.dumps({'location': location, 'problem_id': name})
|
||||
url = "http://127.0.0.1:{port}/test/register_submission".format(port=self.server.port)
|
||||
response = requests.post(url, data={'grader_payload': grader_payload})
|
||||
self.assertTrue(response.ok)
|
||||
|
||||
def _assert_response(self, response, expected_json):
|
||||
"""
|
||||
Assert that the `response` was successful and contained
|
||||
`expected_json` (dict) as its content.
|
||||
"""
|
||||
self.assertTrue(response.ok)
|
||||
self.assertEqual(response.json(), expected_json)
|
||||
|
||||
def _assert_num_graded(self, student_id, location, num_graded, num_required):
|
||||
"""
|
||||
ORA provides two distinct ways to get the submitted/graded counts.
|
||||
Here we check both of them to ensure that the number that we've graded
|
||||
is consistently `num_graded`.
|
||||
"""
|
||||
|
||||
# Unlike the actual ORA service,
|
||||
# we keep track of counts on a per-student basis.
|
||||
# This means that every user starts with N essays to grade,
|
||||
# and as they grade essays, that number decreases.
|
||||
# We do NOT simulate students adding more essays to the queue,
|
||||
# and essays that the current student submits are NOT graded
|
||||
# by other students.
|
||||
num_pending = StudentState.INITIAL_ESSAYS_AVAILABLE - num_graded
|
||||
|
||||
# Notifications
|
||||
response = requests.get(
|
||||
self._peer_url('get_notifications'),
|
||||
params={'student_id': student_id, 'course_id': 'test course'}
|
||||
)
|
||||
self._assert_response(response, {
|
||||
'version': 1, 'success': True,
|
||||
'count_required': num_required,
|
||||
'student_sub_count': self.server.DUMMY_DATA['student_sub_count'],
|
||||
'count_graded': num_graded,
|
||||
'count_available': num_pending
|
||||
})
|
||||
|
||||
# Location data
|
||||
if location is not None:
|
||||
response = requests.get(
|
||||
self._peer_url('get_data_for_location'),
|
||||
params={'location': location, 'student_id': student_id}
|
||||
)
|
||||
self._assert_response(response, {
|
||||
'version': 1, 'success': True,
|
||||
'count_required': num_required,
|
||||
'student_sub_count': self.server.DUMMY_DATA['student_sub_count'],
|
||||
'count_graded': num_graded,
|
||||
'count_available': num_pending
|
||||
})
|
||||
@@ -115,19 +115,6 @@ class StubXQueueServiceTest(unittest.TestCase):
|
||||
self.assertFalse(self.post.called)
|
||||
self.assertTrue(logger.error.called)
|
||||
|
||||
def test_register_submission_url(self):
|
||||
# Configure the XQueue stub to notify another service
|
||||
# when it receives a submission.
|
||||
register_url = 'http://127.0.0.1:8000/register_submission'
|
||||
self.server.config['register_submission_url'] = register_url
|
||||
|
||||
callback_url = 'http://127.0.0.1:8000/test_callback'
|
||||
submission = json.dumps({'grader_payload': 'test payload'})
|
||||
self._post_submission(callback_url, 'test_queuekey', 'test_queue', submission)
|
||||
|
||||
# Check that a notification was sent
|
||||
self.post.assert_any_call(register_url, data={'grader_payload': u'test payload'})
|
||||
|
||||
def _post_submission(self, callback_url, lms_key, queue_name, xqueue_body):
|
||||
"""
|
||||
Post a submission to the stub XQueue implementation.
|
||||
|
||||
@@ -39,7 +39,8 @@ class StubXQueueHandler(StubHttpRequestHandler):
|
||||
if self._is_grade_request():
|
||||
|
||||
# If configured, send the grader payload to other services.
|
||||
self._register_submission(self.post_dict['xqueue_body'])
|
||||
# TODO TNL-3906
|
||||
# self._register_submission(self.post_dict['xqueue_body'])
|
||||
|
||||
try:
|
||||
xqueue_header = json.loads(self.post_dict['xqueue_header'])
|
||||
|
||||
@@ -461,7 +461,7 @@ def set_pipeline_timeout(strategy, user, *args, **kwargs):
|
||||
# choice of the user.
|
||||
|
||||
|
||||
def redirect_to_custom_form(request, auth_entry, user_details):
|
||||
def redirect_to_custom_form(request, auth_entry, kwargs):
|
||||
"""
|
||||
If auth_entry is found in AUTH_ENTRY_CUSTOM, this is used to send provider
|
||||
data to an external server's registration/login page.
|
||||
@@ -469,13 +469,18 @@ def redirect_to_custom_form(request, auth_entry, user_details):
|
||||
The data is sent as a base64-encoded values in a POST request and includes
|
||||
a cryptographic checksum in case the integrity of the data is important.
|
||||
"""
|
||||
backend_name = request.backend.name
|
||||
provider_id = provider.Registry.get_from_pipeline({'backend': backend_name, 'kwargs': kwargs}).provider_id
|
||||
form_info = AUTH_ENTRY_CUSTOM[auth_entry]
|
||||
secret_key = form_info['secret_key']
|
||||
if isinstance(secret_key, unicode):
|
||||
secret_key = secret_key.encode('utf-8')
|
||||
custom_form_url = form_info['url']
|
||||
data_str = json.dumps({
|
||||
"user_details": user_details
|
||||
"auth_entry": auth_entry,
|
||||
"backend_name": backend_name,
|
||||
"provider_id": provider_id,
|
||||
"user_details": kwargs['details'],
|
||||
})
|
||||
digest = hmac.new(secret_key, msg=data_str, digestmod=hashlib.sha256).digest()
|
||||
# Store the data in the session temporarily, then redirect to a page that will POST it to
|
||||
@@ -537,7 +542,7 @@ def ensure_user_information(strategy, auth_entry, backend=None, user=None, socia
|
||||
raise AuthEntryError(backend, 'auth_entry is wrong. Settings requires a user.')
|
||||
elif auth_entry in AUTH_ENTRY_CUSTOM:
|
||||
# Pass the username, email, etc. via query params to the custom entry page:
|
||||
return redirect_to_custom_form(strategy.request, auth_entry, kwargs['details'])
|
||||
return redirect_to_custom_form(strategy.request, auth_entry, kwargs)
|
||||
else:
|
||||
raise AuthEntryError(backend, 'auth_entry invalid')
|
||||
|
||||
|
||||
@@ -80,13 +80,16 @@ class GoogleOauth2IntegrationTest(base.Oauth2IntegrationTest):
|
||||
data_parsed = json.loads(data_decoded)
|
||||
# The user's details get passed to the custom page as a base64 encoded query parameter:
|
||||
self.assertEqual(data_parsed, {
|
||||
'auth_entry': 'custom1',
|
||||
'backend_name': 'google-oauth2',
|
||||
'provider_id': 'oa2-google-oauth2',
|
||||
'user_details': {
|
||||
'username': 'email_value',
|
||||
'email': 'email_value@example.com',
|
||||
'fullname': 'name_value',
|
||||
'first_name': 'given_name_value',
|
||||
'last_name': 'family_name_value',
|
||||
}
|
||||
},
|
||||
})
|
||||
# Check the hash that is used to confirm the user's data in the GET parameter is correct
|
||||
secret_key = settings.THIRD_PARTY_AUTH_CUSTOM_AUTH_FORMS['custom1']['secret_key']
|
||||
|
||||
@@ -74,7 +74,7 @@ def post_to_custom_auth_form(request):
|
||||
# Verify the format of pipeline_data:
|
||||
data = {
|
||||
'post_url': pipeline_data['post_url'],
|
||||
# The user's name, email, etc. as base64 encoded JSON
|
||||
# data: The provider info and user's name, email, etc. as base64 encoded JSON
|
||||
# It's base64 encoded because it's signed cryptographically and we don't want whitespace
|
||||
# or ordering issues affecting the hash/signature.
|
||||
'data': pipeline_data['data'],
|
||||
|
||||
@@ -19,7 +19,7 @@ class TestTrackViews(EventTrackingTestCase):
|
||||
|
||||
self.request_factory = RequestFactory()
|
||||
|
||||
patcher = patch('track.views.tracker')
|
||||
patcher = patch('track.views.tracker', autospec=True)
|
||||
self.mock_tracker = patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ def add_organization(organization_data):
|
||||
"""
|
||||
Client API operation adapter/wrapper
|
||||
"""
|
||||
if not settings.FEATURES.get('ORGANIZATIONS_APP', False):
|
||||
if not organizations_enabled():
|
||||
return None
|
||||
from organizations import api as organizations_api
|
||||
return organizations_api.add_organization(organization_data=organization_data)
|
||||
@@ -20,7 +20,7 @@ def add_organization_course(organization_data, course_id):
|
||||
"""
|
||||
Client API operation adapter/wrapper
|
||||
"""
|
||||
if not settings.FEATURES.get('ORGANIZATIONS_APP', False):
|
||||
if not organizations_enabled():
|
||||
return None
|
||||
from organizations import api as organizations_api
|
||||
return organizations_api.add_organization_course(organization_data=organization_data, course_key=course_id)
|
||||
@@ -30,17 +30,31 @@ def get_organization(organization_id):
|
||||
"""
|
||||
Client API operation adapter/wrapper
|
||||
"""
|
||||
if not settings.FEATURES.get('ORGANIZATIONS_APP', False):
|
||||
if not organizations_enabled():
|
||||
return []
|
||||
from organizations import api as organizations_api
|
||||
return organizations_api.get_organization(organization_id)
|
||||
|
||||
|
||||
def get_organization_by_short_name(organization_short_name):
|
||||
"""
|
||||
Client API operation adapter/wrapper
|
||||
"""
|
||||
if not organizations_enabled():
|
||||
return None
|
||||
from organizations import api as organizations_api
|
||||
from organizations.exceptions import InvalidOrganizationException
|
||||
try:
|
||||
return organizations_api.get_organization_by_short_name(organization_short_name)
|
||||
except InvalidOrganizationException:
|
||||
return None
|
||||
|
||||
|
||||
def get_organizations():
|
||||
"""
|
||||
Client API operation adapter/wrapper
|
||||
"""
|
||||
if not settings.FEATURES.get('ORGANIZATIONS_APP', False):
|
||||
if not organizations_enabled():
|
||||
return []
|
||||
from organizations import api as organizations_api
|
||||
# Due to the way unit tests run for edx-platform, models are not yet available at the time
|
||||
@@ -58,7 +72,7 @@ def get_organization_courses(organization_id):
|
||||
"""
|
||||
Client API operation adapter/wrapper
|
||||
"""
|
||||
if not settings.FEATURES.get('ORGANIZATIONS_APP', False):
|
||||
if not organizations_enabled():
|
||||
return []
|
||||
from organizations import api as organizations_api
|
||||
return organizations_api.get_organization_courses(organization_id)
|
||||
@@ -68,7 +82,14 @@ def get_course_organizations(course_id):
|
||||
"""
|
||||
Client API operation adapter/wrapper
|
||||
"""
|
||||
if not settings.FEATURES.get('ORGANIZATIONS_APP', False):
|
||||
if not organizations_enabled():
|
||||
return []
|
||||
from organizations import api as organizations_api
|
||||
return organizations_api.get_course_organizations(course_id)
|
||||
|
||||
|
||||
def organizations_enabled():
|
||||
"""
|
||||
Returns boolean indication if organizations app is enabled on not.
|
||||
"""
|
||||
return settings.FEATURES.get('ORGANIZATIONS_APP', False)
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
"""
|
||||
Utility function for some parsing stuff
|
||||
"""
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
|
||||
|
||||
def course_image_url(course):
|
||||
"""
|
||||
Return url of course image.
|
||||
Args:
|
||||
course(CourseDescriptor) : The course id to retrieve course image url.
|
||||
Returns:
|
||||
Absolute url of course image.
|
||||
"""
|
||||
loc = StaticContent.compute_location(course.id, course.course_image)
|
||||
url = StaticContent.serialize_asset_key_with_slash(loc)
|
||||
return url
|
||||
@@ -23,6 +23,7 @@ class OrganizationsHelpersTestCase(ModuleStoreTestCase):
|
||||
|
||||
self.organization = {
|
||||
'name': 'Test Organization',
|
||||
'short_name': 'Orgx',
|
||||
'description': 'Testing Organization Helpers Library',
|
||||
}
|
||||
|
||||
@@ -49,3 +50,25 @@ class OrganizationsHelpersTestCase(ModuleStoreTestCase):
|
||||
def test_add_organization_course_returns_none_when_app_disabled(self):
|
||||
response = organizations_helpers.add_organization_course(self.organization, self.course.id)
|
||||
self.assertIsNone(response)
|
||||
|
||||
def test_get_organization_by_short_name_when_app_disabled(self):
|
||||
"""
|
||||
Tests get_organization_by_short_name api when app is disabled.
|
||||
"""
|
||||
response = organizations_helpers.get_organization_by_short_name(self.organization['short_name'])
|
||||
self.assertIsNone(response)
|
||||
|
||||
@patch.dict('django.conf.settings.FEATURES', {'ORGANIZATIONS_APP': True})
|
||||
def test_get_organization_by_short_name_when_app_enabled(self):
|
||||
"""
|
||||
Tests get_organization_by_short_name api when app is enabled.
|
||||
"""
|
||||
response = organizations_helpers.add_organization(organization_data=self.organization)
|
||||
self.assertIsNotNone(response['id'])
|
||||
|
||||
response = organizations_helpers.get_organization_by_short_name(self.organization['short_name'])
|
||||
self.assertIsNotNone(response['id'])
|
||||
|
||||
# fetch non existing org
|
||||
response = organizations_helpers.get_organization_by_short_name('non_existing')
|
||||
self.assertIsNone(response)
|
||||
|
||||
@@ -233,7 +233,7 @@ def _record_feedback_in_zendesk(
|
||||
new_ticket['ticket']['group_id'] = group['id']
|
||||
try:
|
||||
ticket_id = zendesk_api.create_ticket(new_ticket)
|
||||
if group is None:
|
||||
if group_name is not None and group is None:
|
||||
# Support uses Zendesk groups to track tickets. In case we
|
||||
# haven't been able to correctly group this ticket, log its ID
|
||||
# so it can be found later.
|
||||
|
||||
@@ -78,7 +78,8 @@ def to_latex(expr):
|
||||
# substitute back into latex form for scripts
|
||||
# literally something of the form
|
||||
# 'scriptN' becomes '\\mathcal{N}'
|
||||
# note: can't use something akin to the _print_hat method above because we sometimes get 'script(N)__B' or more complicated terms
|
||||
# note: can't use something akin to the _print_hat method above because we
|
||||
# sometimes get 'script(N)__B' or more complicated terms
|
||||
expr_s = re.sub(
|
||||
r'script([a-zA-Z0-9]+)',
|
||||
'\\mathcal{\\1}',
|
||||
@@ -99,11 +100,11 @@ def my_evalf(expr, chop=False):
|
||||
if isinstance(expr, list):
|
||||
try:
|
||||
return [x.evalf(chop=chop) for x in expr]
|
||||
except:
|
||||
except Exception: # pylint: disable=broad-except
|
||||
return expr
|
||||
try:
|
||||
return expr.evalf(chop=chop)
|
||||
except:
|
||||
except Exception: # pylint: disable=broad-except
|
||||
return expr
|
||||
|
||||
|
||||
@@ -115,23 +116,25 @@ def my_sympify(expr, normphase=False, matrix=False, abcsym=False, do_qubit=False
|
||||
if symtab:
|
||||
varset = symtab
|
||||
else:
|
||||
varset = {'p': sympy.Symbol('p'),
|
||||
'g': sympy.Symbol('g'),
|
||||
'e': sympy.E, # for exp
|
||||
'i': sympy.I, # lowercase i is also sqrt(-1)
|
||||
'Q': sympy.Symbol('Q'), # otherwise it is a sympy "ask key"
|
||||
'I': sympy.Symbol('I'), # otherwise it is sqrt(-1)
|
||||
'N': sympy.Symbol('N'), # or it is some kind of sympy function
|
||||
'ZZ': sympy.Symbol('ZZ'), # otherwise it is the PythonIntegerRing
|
||||
'XI': sympy.Symbol('XI'), # otherwise it is the capital \XI
|
||||
'hat': sympy.Function('hat'), # for unit vectors (8.02)
|
||||
}
|
||||
varset = {
|
||||
'p': sympy.Symbol('p'),
|
||||
'g': sympy.Symbol('g'),
|
||||
'e': sympy.E, # for exp
|
||||
'i': sympy.I, # lowercase i is also sqrt(-1)
|
||||
'Q': sympy.Symbol('Q'), # otherwise it is a sympy "ask key"
|
||||
'I': sympy.Symbol('I'), # otherwise it is sqrt(-1)
|
||||
'N': sympy.Symbol('N'), # or it is some kind of sympy function
|
||||
'ZZ': sympy.Symbol('ZZ'), # otherwise it is the PythonIntegerRing
|
||||
'XI': sympy.Symbol('XI'), # otherwise it is the capital \XI
|
||||
'hat': sympy.Function('hat'), # for unit vectors (8.02)
|
||||
}
|
||||
if do_qubit: # turn qubit(...) into Qubit instance
|
||||
varset.update({'qubit': Qubit,
|
||||
'Ket': Ket,
|
||||
'dot': dot,
|
||||
'bit': sympy.Function('bit'),
|
||||
})
|
||||
varset.update({
|
||||
'qubit': Qubit,
|
||||
'Ket': Ket,
|
||||
'dot': dot,
|
||||
'bit': sympy.Function('bit'),
|
||||
})
|
||||
if abcsym: # consider all lowercase letters as real symbols, in the parsing
|
||||
for letter in string.lowercase:
|
||||
if letter in varset: # exclude those already done
|
||||
@@ -207,7 +210,7 @@ class formula(object):
|
||||
usym = unicode(k.text)
|
||||
try:
|
||||
udata = unicodedata.name(usym)
|
||||
except Exception:
|
||||
except Exception: # pylint: disable=broad-except
|
||||
udata = None
|
||||
# print "usym = %s, udata=%s" % (usym,udata)
|
||||
if udata: # eg "GREEK SMALL LETTER BETA"
|
||||
@@ -271,7 +274,8 @@ class formula(object):
|
||||
newk = etree.Element('mi')
|
||||
newk.text = 'hat(%s)' % k[0].text
|
||||
xml.replace(k, newk)
|
||||
if gettag(k[0]) == 'mrow' and gettag(k[0][0]) == 'mi' and gettag(k[1]) == 'mo' and str(k[1].text) == '^':
|
||||
if gettag(k[0]) == 'mrow' and gettag(k[0][0]) == 'mi' and \
|
||||
gettag(k[1]) == 'mo' and str(k[1].text) == '^':
|
||||
newk = etree.Element('mi')
|
||||
newk.text = 'hat(%s)' % k[0][0].text
|
||||
xml.replace(k, newk)
|
||||
@@ -419,7 +423,7 @@ class formula(object):
|
||||
# pre-process the presentation mathml before sending it to snuggletex to convert to content mathml
|
||||
try:
|
||||
xml = self.preprocess_pmathml(self.expr)
|
||||
except Exception, err:
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
log.warning('Err %s while preprocessing; expr=%s', err, self.expr)
|
||||
return "<html>Error! Cannot process pmathml</html>"
|
||||
pmathml = etree.tostring(xml, pretty_print=True)
|
||||
@@ -468,13 +472,6 @@ class formula(object):
|
||||
def gettag(expr):
|
||||
return re.sub('{http://[^}]+}', '', expr.tag)
|
||||
|
||||
# simple math
|
||||
def op_divide(*args):
|
||||
if not len(args) == 2:
|
||||
raise Exception('divide given wrong number of arguments!')
|
||||
# print "divide: arg0=%s, arg1=%s" % (args[0],args[1])
|
||||
return sympy.Mul(args[0], sympy.Pow(args[1], -1))
|
||||
|
||||
def op_plus(*args):
|
||||
return args[0] if len(args) == 1 else op_plus(*args[:-1]) + args[-1]
|
||||
|
||||
@@ -491,7 +488,7 @@ class formula(object):
|
||||
|
||||
opdict = {
|
||||
'plus': op_plus,
|
||||
'divide': operator.div, # should this be op_divide?
|
||||
'divide': operator.div,
|
||||
'times': op_times,
|
||||
'minus': op_minus,
|
||||
'root': sympy.sqrt,
|
||||
@@ -518,12 +515,7 @@ class formula(object):
|
||||
'ln': sympy.ln,
|
||||
}
|
||||
|
||||
# simple symbols - TODO is this code used?
|
||||
nums1dict = {
|
||||
'pi': sympy.pi,
|
||||
}
|
||||
|
||||
def parsePresentationMathMLSymbol(xml):
|
||||
def parse_presentation_symbol(xml):
|
||||
"""
|
||||
Parse <msub>, <msup>, <mi>, and <mn>
|
||||
"""
|
||||
@@ -533,10 +525,10 @@ class formula(object):
|
||||
elif tag == 'mi':
|
||||
return xml.text
|
||||
elif tag == 'msub':
|
||||
return '_'.join([parsePresentationMathMLSymbol(y) for y in xml])
|
||||
return '_'.join([parse_presentation_symbol(y) for y in xml])
|
||||
elif tag == 'msup':
|
||||
return '^'.join([parsePresentationMathMLSymbol(y) for y in xml])
|
||||
raise Exception('[parsePresentationMathMLSymbol] unknown tag %s' % tag)
|
||||
return '^'.join([parse_presentation_symbol(y) for y in xml])
|
||||
raise Exception('[parse_presentation_symbol] unknown tag %s' % tag)
|
||||
|
||||
# parser tree for Content MathML
|
||||
tag = gettag(xml)
|
||||
@@ -574,11 +566,10 @@ class formula(object):
|
||||
|
||||
elif tag == 'cn': # number
|
||||
return sympy.sympify(xml.text)
|
||||
# return float(xml.text)
|
||||
|
||||
elif tag == 'ci': # variable (symbol)
|
||||
if len(xml) > 0 and (gettag(xml[0]) == 'msub' or gettag(xml[0]) == 'msup'): # subscript or superscript
|
||||
usym = parsePresentationMathMLSymbol(xml[0])
|
||||
usym = parse_presentation_symbol(xml[0])
|
||||
sym = sympy.Symbol(str(usym))
|
||||
else:
|
||||
usym = unicode(xml.text)
|
||||
@@ -596,25 +587,22 @@ class formula(object):
|
||||
|
||||
sympy = property(make_sympy, None, None, 'sympy representation')
|
||||
|
||||
def GetContentMathML(self, asciimath, mathml):
|
||||
def GetContentMathML(self, asciimath, mathml): # pylint: disable=invalid-name
|
||||
"""
|
||||
Handle requests to snuggletex API to convert the Ascii math to MathML
|
||||
"""
|
||||
# url = 'http://192.168.1.2:8080/snuggletex-webapp-1.2.2/ASCIIMathMLUpConversionDemo'
|
||||
# url = 'http://127.0.0.1:8080/snuggletex-webapp-1.2.2/ASCIIMathMLUpConversionDemo'
|
||||
url = 'https://math-xserver.mitx.mit.edu/snuggletex-webapp-1.2.2/ASCIIMathMLUpConversionDemo'
|
||||
|
||||
if 1:
|
||||
payload = {
|
||||
'asciiMathInput': asciimath,
|
||||
'asciiMathML': mathml,
|
||||
#'asciiMathML':unicode(mathml).encode('utf-8'),
|
||||
}
|
||||
headers = {'User-Agent': "Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.8.1.13) Gecko/20080311 Firefox/2.0.0.13"}
|
||||
request = requests.post(url, data=payload, headers=headers, verify=False)
|
||||
request.encoding = 'utf-8'
|
||||
ret = request.text
|
||||
# print "encoding: ", request.encoding
|
||||
payload = {
|
||||
'asciiMathInput': asciimath,
|
||||
'asciiMathML': mathml,
|
||||
}
|
||||
headers = {
|
||||
'User-Agent': "Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.8.1.13) Gecko/20080311 Firefox/2.0.0.13"
|
||||
}
|
||||
request = requests.post(url, data=payload, headers=headers, verify=False)
|
||||
request.encoding = 'utf-8'
|
||||
ret = request.text
|
||||
|
||||
mode = 0
|
||||
cmathml = []
|
||||
@@ -629,153 +617,4 @@ class formula(object):
|
||||
cmathml.append(k)
|
||||
cmathml = '\n'.join(cmathml[2:])
|
||||
cmathml = '<math xmlns="http://www.w3.org/1998/Math/MathML">\n' + unescape(cmathml) + '\n</math>'
|
||||
# print cmathml
|
||||
return cmathml
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test1():
|
||||
"""Test XML strings - addition"""
|
||||
xmlstr = """
|
||||
<math xmlns="http://www.w3.org/1998/Math/MathML">
|
||||
<apply>
|
||||
<plus/>
|
||||
<cn>1</cn>
|
||||
<cn>2</cn>
|
||||
</apply>
|
||||
</math>
|
||||
"""
|
||||
return formula(xmlstr)
|
||||
|
||||
|
||||
def test2():
|
||||
"""Test XML strings - addition, Greek alpha"""
|
||||
xmlstr = u"""
|
||||
<math xmlns="http://www.w3.org/1998/Math/MathML">
|
||||
<apply>
|
||||
<plus/>
|
||||
<cn>1</cn>
|
||||
<apply>
|
||||
<times/>
|
||||
<cn>2</cn>
|
||||
<ci>α</ci>
|
||||
</apply>
|
||||
</apply>
|
||||
</math>
|
||||
"""
|
||||
return formula(xmlstr)
|
||||
|
||||
|
||||
def test3():
|
||||
"""Test XML strings - addition, Greek gamma"""
|
||||
xmlstr = """
|
||||
<math xmlns="http://www.w3.org/1998/Math/MathML">
|
||||
<apply>
|
||||
<divide/>
|
||||
<cn>1</cn>
|
||||
<apply>
|
||||
<plus/>
|
||||
<cn>2</cn>
|
||||
<ci>γ</ci>
|
||||
</apply>
|
||||
</apply>
|
||||
</math>
|
||||
"""
|
||||
return formula(xmlstr)
|
||||
|
||||
|
||||
def test4():
|
||||
"""Test XML strings - addition, Greek alpha, mfrac"""
|
||||
xmlstr = u"""
|
||||
<math xmlns="http://www.w3.org/1998/Math/MathML">
|
||||
<mstyle displaystyle="true">
|
||||
<mn>1</mn>
|
||||
<mo>+</mo>
|
||||
<mfrac>
|
||||
<mn>2</mn>
|
||||
<mi>α</mi>
|
||||
</mfrac>
|
||||
</mstyle>
|
||||
</math>
|
||||
"""
|
||||
return formula(xmlstr)
|
||||
|
||||
|
||||
def test5():
|
||||
"""Test XML strings - sum of two matrices"""
|
||||
xmlstr = u"""
|
||||
<math xmlns="http://www.w3.org/1998/Math/MathML">
|
||||
<mstyle displaystyle="true">
|
||||
<mrow>
|
||||
<mi>cos</mi>
|
||||
<mrow>
|
||||
<mo>(</mo>
|
||||
<mi>θ</mi>
|
||||
<mo>)</mo>
|
||||
</mrow>
|
||||
</mrow>
|
||||
<mo>⋅</mo>
|
||||
<mrow>
|
||||
<mo>[</mo>
|
||||
<mtable>
|
||||
<mtr>
|
||||
<mtd>
|
||||
<mn>1</mn>
|
||||
</mtd>
|
||||
<mtd>
|
||||
<mn>0</mn>
|
||||
</mtd>
|
||||
</mtr>
|
||||
<mtr>
|
||||
<mtd>
|
||||
<mn>0</mn>
|
||||
</mtd>
|
||||
<mtd>
|
||||
<mn>1</mn>
|
||||
</mtd>
|
||||
</mtr>
|
||||
</mtable>
|
||||
<mo>]</mo>
|
||||
</mrow>
|
||||
<mo>+</mo>
|
||||
<mrow>
|
||||
<mo>[</mo>
|
||||
<mtable>
|
||||
<mtr>
|
||||
<mtd>
|
||||
<mn>0</mn>
|
||||
</mtd>
|
||||
<mtd>
|
||||
<mn>1</mn>
|
||||
</mtd>
|
||||
</mtr>
|
||||
<mtr>
|
||||
<mtd>
|
||||
<mn>1</mn>
|
||||
</mtd>
|
||||
<mtd>
|
||||
<mn>0</mn>
|
||||
</mtd>
|
||||
</mtr>
|
||||
</mtable>
|
||||
<mo>]</mo>
|
||||
</mrow>
|
||||
</mstyle>
|
||||
</math>
|
||||
"""
|
||||
return formula(xmlstr)
|
||||
|
||||
|
||||
def test6():
|
||||
"""Test XML strings - imaginary numbers"""
|
||||
xmlstr = u"""
|
||||
<math xmlns="http://www.w3.org/1998/Math/MathML">
|
||||
<mstyle displaystyle="true">
|
||||
<mn>1</mn>
|
||||
<mo>+</mo>
|
||||
<mi>i</mi>
|
||||
</mstyle>
|
||||
</math>
|
||||
"""
|
||||
return formula(xmlstr, options='imaginary')
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
from setuptools import setup, find_packages
|
||||
|
||||
XMODULES = [
|
||||
"abtest = xmodule.abtest_module:ABTestDescriptor",
|
||||
"book = xmodule.backcompat_module:TranslateCustomTagDescriptor",
|
||||
"chapter = xmodule.seq_module:SequenceDescriptor",
|
||||
"combinedopenended = xmodule.combined_open_ended_module:CombinedOpenEndedDescriptor",
|
||||
"conditional = xmodule.conditional_module:ConditionalDescriptor",
|
||||
"course = xmodule.course_module:CourseDescriptor",
|
||||
"customtag = xmodule.template_module:CustomTagDescriptor",
|
||||
@@ -13,7 +11,6 @@ XMODULES = [
|
||||
"image = xmodule.backcompat_module:TranslateCustomTagDescriptor",
|
||||
"library_content = xmodule.library_content_module:LibraryContentDescriptor",
|
||||
"error = xmodule.error_module:ErrorDescriptor",
|
||||
"peergrading = xmodule.peer_grading_module:PeerGradingDescriptor",
|
||||
"poll_question = xmodule.poll_module:PollDescriptor",
|
||||
"problem = xmodule.capa_module:CapaDescriptor",
|
||||
"problemset = xmodule.seq_module:SequenceDescriptor",
|
||||
@@ -36,7 +33,6 @@ XMODULES = [
|
||||
"textannotation = xmodule.textannotation_module:TextAnnotationDescriptor",
|
||||
"videoannotation = xmodule.videoannotation_module:VideoAnnotationDescriptor",
|
||||
"imageannotation = xmodule.imageannotation_module:ImageAnnotationDescriptor",
|
||||
"foldit = xmodule.foldit_module:FolditDescriptor",
|
||||
"word_cloud = xmodule.word_cloud_module:WordCloudDescriptor",
|
||||
"hidden = xmodule.hidden_module:HiddenDescriptor",
|
||||
"raw = xmodule.raw_module:RawDescriptor",
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
import random
|
||||
import logging
|
||||
from lxml import etree
|
||||
|
||||
from xmodule.x_module import XModule
|
||||
from xmodule.raw_module import RawDescriptor
|
||||
from xmodule.xml_module import XmlDescriptor
|
||||
from xmodule.exceptions import InvalidDefinitionError
|
||||
from xblock.fields import String, Scope, Dict
|
||||
|
||||
DEFAULT = "_DEFAULT_GROUP"
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def group_from_value(groups, v):
|
||||
"""
|
||||
Given group: (('a', 0.3), ('b', 0.4), ('c', 0.3)) and random value v
|
||||
in [0,1], return the associated group (in the above case, return
|
||||
'a' if v < 0.3, 'b' if 0.3 <= v < 0.7, and 'c' if v > 0.7
|
||||
"""
|
||||
sum = 0
|
||||
for (g, p) in groups:
|
||||
sum = sum + p
|
||||
if sum > v:
|
||||
return g
|
||||
|
||||
# Round off errors might cause us to run to the end of the list.
|
||||
# If the do, return the last element.
|
||||
return g
|
||||
|
||||
|
||||
class ABTestFields(object):
|
||||
group_portions = Dict(help="What proportions of students should go in each group", default={DEFAULT: 1}, scope=Scope.content)
|
||||
group_assignments = Dict(help="What group this user belongs to", scope=Scope.preferences, default={})
|
||||
group_content = Dict(help="What content to display to each group", scope=Scope.content, default={DEFAULT: []})
|
||||
experiment = String(help="Experiment that this A/B test belongs to", scope=Scope.content)
|
||||
has_children = True
|
||||
|
||||
|
||||
class ABTestModule(ABTestFields, XModule):
|
||||
"""
|
||||
Implements an A/B test with an aribtrary number of competing groups
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(ABTestModule, self).__init__(*args, **kwargs)
|
||||
|
||||
if self.group is None:
|
||||
self.group = group_from_value(
|
||||
self.group_portions.items(),
|
||||
random.uniform(0, 1)
|
||||
)
|
||||
|
||||
@property
|
||||
def group(self):
|
||||
return self.group_assignments.get(self.experiment)
|
||||
|
||||
@group.setter
|
||||
def group(self, value):
|
||||
self.group_assignments[self.experiment] = value
|
||||
|
||||
@group.deleter
|
||||
def group(self):
|
||||
del self.group_assignments[self.experiment]
|
||||
|
||||
def get_child_descriptors(self):
|
||||
active_locations = set(self.group_content[self.group])
|
||||
return [desc for desc in self.descriptor.get_children() if desc.location.to_deprecated_string() in active_locations]
|
||||
|
||||
def displayable_items(self):
|
||||
# Most modules return "self" as the displayable_item. We never display ourself
|
||||
# (which is why we don't implement get_html). We only display our children.
|
||||
return self.get_children()
|
||||
|
||||
|
||||
# TODO (cpennington): Use Groups should be a first class object, rather than being
|
||||
# managed by ABTests
|
||||
class ABTestDescriptor(ABTestFields, RawDescriptor, XmlDescriptor):
|
||||
module_class = ABTestModule
|
||||
|
||||
show_in_read_only_mode = True
|
||||
|
||||
@classmethod
|
||||
def definition_from_xml(cls, xml_object, system):
|
||||
"""
|
||||
XML Format:
|
||||
<abtest experiment="experiment_name">
|
||||
<group name="a" portion=".1"><contenta/></group>
|
||||
<group name="b" portion=".2"><contentb/></group>
|
||||
<default><contentdefault/></default>
|
||||
</abtest>
|
||||
"""
|
||||
experiment = xml_object.get('experiment')
|
||||
|
||||
if experiment is None:
|
||||
raise InvalidDefinitionError(
|
||||
"ABTests must specify an experiment. Not found in:\n{xml}"
|
||||
.format(xml=etree.tostring(xml_object, pretty_print=True)))
|
||||
|
||||
group_portions = {}
|
||||
group_content = {}
|
||||
children = []
|
||||
|
||||
for group in xml_object:
|
||||
if group.tag == 'default':
|
||||
name = DEFAULT
|
||||
else:
|
||||
name = group.get('name')
|
||||
group_portions[name] = float(group.get('portion', 0))
|
||||
|
||||
child_content_urls = []
|
||||
for child in group:
|
||||
try:
|
||||
child_block = system.process_xml(etree.tostring(child))
|
||||
child_content_urls.append(child_block.scope_ids.usage_id)
|
||||
except:
|
||||
log.exception("Unable to load child when parsing ABTest. Continuing...")
|
||||
continue
|
||||
|
||||
group_content[name] = child_content_urls
|
||||
children.extend(child_content_urls)
|
||||
|
||||
default_portion = 1 - sum(
|
||||
portion for (name, portion) in group_portions.items()
|
||||
)
|
||||
|
||||
if default_portion < 0:
|
||||
raise InvalidDefinitionError("ABTest portions must add up to less than or equal to 1")
|
||||
|
||||
group_portions[DEFAULT] = default_portion
|
||||
children.sort()
|
||||
|
||||
return {
|
||||
'group_portions': group_portions,
|
||||
'group_content': group_content,
|
||||
}, children
|
||||
|
||||
def definition_to_xml(self, resource_fs):
|
||||
xml_object = etree.Element('abtest')
|
||||
xml_object.set('experiment', self.experiment)
|
||||
for name, group in self.group_content.items():
|
||||
if name == DEFAULT:
|
||||
group_elem = etree.SubElement(xml_object, 'default')
|
||||
else:
|
||||
group_elem = etree.SubElement(xml_object, 'group', attrib={
|
||||
'portion': str(self.group_portions[name]),
|
||||
'name': name,
|
||||
})
|
||||
|
||||
for child_loc in group:
|
||||
child = self.system.load_item(child_loc)
|
||||
self.runtime.add_block_as_child_node(child, group_elem)
|
||||
|
||||
return xml_object
|
||||
|
||||
def has_dynamic_children(self):
|
||||
return True
|
||||
@@ -1125,9 +1125,6 @@ class CapaMixin(CapaFields):
|
||||
self.attempts,
|
||||
)
|
||||
|
||||
if hasattr(self.runtime, 'psychometrics_handler'): # update PsychometricsData using callback
|
||||
self.runtime.psychometrics_handler(self.get_state_for_lcp())
|
||||
|
||||
# render problem into HTML
|
||||
html = self.get_problem_html(encapsulate=False)
|
||||
|
||||
@@ -1375,10 +1372,6 @@ class CapaMixin(CapaFields):
|
||||
event_info['attempts'] = self.attempts
|
||||
self.track_function_unmask('problem_rescore', event_info)
|
||||
|
||||
# psychometrics should be called on rescoring requests in the same way as check-problem
|
||||
if hasattr(self.runtime, 'psychometrics_handler'): # update PsychometricsData using callback
|
||||
self.runtime.psychometrics_handler(self.get_state_for_lcp())
|
||||
|
||||
return {'success': success}
|
||||
|
||||
def save_problem(self, data):
|
||||
|
||||
@@ -1,550 +0,0 @@
|
||||
"""
|
||||
ORA1. Deprecated.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from lxml import etree
|
||||
from pkg_resources import resource_string
|
||||
|
||||
from xmodule.raw_module import RawDescriptor
|
||||
from .x_module import XModule, module_attr
|
||||
from xblock.fields import Integer, Scope, String, List, Float, Boolean
|
||||
from xmodule.open_ended_grading_classes.combined_open_ended_modulev1 import CombinedOpenEndedV1Module, CombinedOpenEndedV1Descriptor
|
||||
from xmodule.validation import StudioValidation, StudioValidationMessage
|
||||
|
||||
from collections import namedtuple
|
||||
from .fields import Date, Timedelta
|
||||
import textwrap
|
||||
|
||||
log = logging.getLogger("edx.courseware")
|
||||
|
||||
# Make '_' a no-op so we can scrape strings. Using lambda instead of
|
||||
# `django.utils.translation.ugettext_noop` because Django cannot be imported in this file
|
||||
_ = lambda text: text
|
||||
|
||||
V1_SETTINGS_ATTRIBUTES = [
|
||||
"display_name",
|
||||
"max_attempts",
|
||||
"graded",
|
||||
"accept_file_upload",
|
||||
"skip_spelling_checks",
|
||||
"due",
|
||||
"graceperiod",
|
||||
"weight",
|
||||
"min_to_calibrate",
|
||||
"max_to_calibrate",
|
||||
"peer_grader_count",
|
||||
"required_peer_grading",
|
||||
"peer_grade_finished_submissions_when_none_pending",
|
||||
]
|
||||
|
||||
V1_STUDENT_ATTRIBUTES = [
|
||||
"current_task_number",
|
||||
"task_states",
|
||||
"state",
|
||||
"student_attempts",
|
||||
"ready_to_reset",
|
||||
"old_task_states",
|
||||
]
|
||||
|
||||
V1_ATTRIBUTES = V1_SETTINGS_ATTRIBUTES + V1_STUDENT_ATTRIBUTES
|
||||
|
||||
VersionTuple = namedtuple('VersionTuple', ['descriptor', 'module', 'settings_attributes', 'student_attributes'])
|
||||
VERSION_TUPLES = {
|
||||
1: VersionTuple(CombinedOpenEndedV1Descriptor, CombinedOpenEndedV1Module, V1_SETTINGS_ATTRIBUTES,
|
||||
V1_STUDENT_ATTRIBUTES),
|
||||
}
|
||||
|
||||
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 views 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):
|
||||
"""
|
||||
A model type that converts from strings to integers when reading from json.
|
||||
Also does error checking to see if version is correct or not.
|
||||
"""
|
||||
|
||||
def from_json(self, value):
|
||||
try:
|
||||
value = int(value)
|
||||
if value not in VERSION_TUPLES:
|
||||
version_error_string = "Could not find version {0}, using version {1} instead"
|
||||
log.error(version_error_string.format(value, DEFAULT_VERSION))
|
||||
value = DEFAULT_VERSION
|
||||
except:
|
||||
value = DEFAULT_VERSION
|
||||
return value
|
||||
|
||||
|
||||
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 Response Assessment"),
|
||||
scope=Scope.settings
|
||||
)
|
||||
current_task_number = Integer(
|
||||
help=_("Current task that the student is on."),
|
||||
default=0,
|
||||
scope=Scope.user_state
|
||||
)
|
||||
old_task_states = List(
|
||||
help=_("A list of lists of state dictionaries for student states that are saved. "
|
||||
"This field is only populated if the instructor changes tasks after "
|
||||
"the module is created and students have attempted it (for example, if a self assessed problem is "
|
||||
"changed to self and peer assessed)."),
|
||||
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
|
||||
)
|
||||
graded = Boolean(
|
||||
display_name=_("Graded"),
|
||||
help=_("Defines whether the student gets credit for this problem. Credit is based on peer grades of 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}
|
||||
)
|
||||
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
|
||||
)
|
||||
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
|
||||
)
|
||||
due = Date(
|
||||
help=_("Date that this problem is due by"),
|
||||
scope=Scope.settings
|
||||
)
|
||||
graceperiod = Timedelta(
|
||||
help=_("Amount of time after the due date that submissions will be accepted"),
|
||||
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,
|
||||
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"},
|
||||
default=1
|
||||
)
|
||||
min_to_calibrate = Integer(
|
||||
display_name=_("Minimum Peer Grading Calibrations"),
|
||||
help=_("The minimum number of calibration essays each student will need to complete for peer grading."),
|
||||
default=3,
|
||||
scope=Scope.settings,
|
||||
values={"min": 1, "max": 20, "step": "1"}
|
||||
)
|
||||
max_to_calibrate = Integer(
|
||||
display_name=_("Maximum Peer Grading Calibrations"),
|
||||
help=_("The maximum number of calibration essays each student will need to complete for peer grading."),
|
||||
default=6,
|
||||
scope=Scope.settings,
|
||||
values={"min": 1, "max": 20, "step": "1"}
|
||||
)
|
||||
peer_grader_count = Integer(
|
||||
display_name=_("Peer Graders per Response"),
|
||||
help=_("The number of peers who will grade each submission."),
|
||||
default=3,
|
||||
scope=Scope.settings,
|
||||
values={"min": 1, "step": "1", "max": 5}
|
||||
)
|
||||
required_peer_grading = Integer(
|
||||
display_name=_("Required Peer Grading"),
|
||||
help=_("The number of other students each student making a submission will have to grade."),
|
||||
default=3,
|
||||
scope=Scope.settings,
|
||||
values={"min": 1, "step": "1", "max": 5}
|
||||
)
|
||||
peer_grade_finished_submissions_when_none_pending = Boolean(
|
||||
display_name=_('Allow "overgrading" of peer submissions'),
|
||||
help=_(
|
||||
"EXPERIMENTAL FEATURE. Allow students to peer grade submissions that already have the requisite number of graders, "
|
||||
"but ONLY WHEN all submissions they are eligible to grade already have enough graders. "
|
||||
"This is intended for use when settings for `Required Peer Grading` > `Peer Graders per Response`"
|
||||
),
|
||||
default=False,
|
||||
scope=Scope.settings,
|
||||
)
|
||||
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 views 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
|
||||
)
|
||||
|
||||
|
||||
class CombinedOpenEndedModule(CombinedOpenEndedFields, XModule):
|
||||
"""
|
||||
This is a module that encapsulates all open ended grading (self assessment, peer assessment, etc).
|
||||
It transitions between problems, and support arbitrary ordering.
|
||||
Each combined open ended module contains one or multiple "child" modules.
|
||||
Child modules track their own state, and can transition between states. They also implement get_html and
|
||||
handle_ajax.
|
||||
The combined open ended module transitions between child modules as appropriate, tracks its own state, and passess
|
||||
ajax requests from the browser to the child module or handles them itself (in the cases of reset and next problem)
|
||||
ajax actions implemented by all children are:
|
||||
'save_answer' -- Saves the student answer
|
||||
'save_assessment' -- Saves the student assessment (or external grader assessment)
|
||||
'save_post_assessment' -- saves a post assessment (hint, feedback on feedback, etc)
|
||||
ajax actions implemented by combined open ended module are:
|
||||
'reset' -- resets the whole combined open ended module and returns to the first child module
|
||||
'next_problem' -- moves to the next child module
|
||||
'get_results' -- gets results from a given child module
|
||||
|
||||
Types of children. Task is synonymous with child module, so each combined open ended module
|
||||
incorporates multiple children (tasks):
|
||||
openendedmodule
|
||||
selfassessmentmodule
|
||||
|
||||
CombinedOpenEndedModule.__init__ takes the same arguments as xmodule.x_module:XModule.__init__
|
||||
"""
|
||||
STATE_VERSION = 1
|
||||
|
||||
# states
|
||||
INITIAL = 'initial'
|
||||
ASSESSING = 'assessing'
|
||||
INTERMEDIATE_DONE = 'intermediate_done'
|
||||
DONE = 'done'
|
||||
|
||||
icon_class = 'problem'
|
||||
|
||||
js = {
|
||||
'coffee': [
|
||||
resource_string(__name__, 'js/src/combinedopenended/display.coffee'),
|
||||
resource_string(__name__, 'js/src/javascript_loader.coffee'),
|
||||
],
|
||||
'js': [
|
||||
resource_string(__name__, 'js/src/collapsible.js'),
|
||||
]
|
||||
}
|
||||
js_module_name = "CombinedOpenEnded"
|
||||
|
||||
css = {'scss': [resource_string(__name__, 'css/combinedopenended/display.scss')]}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""
|
||||
Definition file should have one or many task blocks, a rubric block, and a prompt block.
|
||||
|
||||
See DEFAULT_DATA for a sample.
|
||||
|
||||
"""
|
||||
super(CombinedOpenEndedModule, self).__init__(*args, **kwargs)
|
||||
|
||||
self.system.set('location', self.location)
|
||||
|
||||
if self.task_states is None:
|
||||
self.task_states = []
|
||||
|
||||
if self.old_task_states is None:
|
||||
self.old_task_states = []
|
||||
|
||||
version_tuple = VERSION_TUPLES[self.version]
|
||||
|
||||
self.student_attributes = version_tuple.student_attributes
|
||||
self.settings_attributes = version_tuple.settings_attributes
|
||||
|
||||
attributes = self.student_attributes + self.settings_attributes
|
||||
|
||||
static_data = {}
|
||||
instance_state = {k: getattr(self, k) for k in attributes}
|
||||
self.child_descriptor = version_tuple.descriptor(self.system)
|
||||
self.child_definition = version_tuple.descriptor.definition_from_xml(etree.fromstring(self.data), self.system)
|
||||
self.child_module = version_tuple.module(self.system, self.location, self.child_definition, self.child_descriptor,
|
||||
instance_state=instance_state, static_data=static_data,
|
||||
attributes=attributes)
|
||||
self.save_instance_data()
|
||||
|
||||
def get_html(self):
|
||||
self.save_instance_data()
|
||||
return_value = self.child_module.get_html()
|
||||
return return_value
|
||||
|
||||
def handle_ajax(self, dispatch, data):
|
||||
self.save_instance_data()
|
||||
return_value = self.child_module.handle_ajax(dispatch, data)
|
||||
self.save_instance_data()
|
||||
return return_value
|
||||
|
||||
def get_instance_state(self):
|
||||
return self.child_module.get_instance_state()
|
||||
|
||||
def get_score(self):
|
||||
return self.child_module.get_score()
|
||||
|
||||
def max_score(self):
|
||||
return self.child_module.max_score()
|
||||
|
||||
def get_progress(self):
|
||||
return self.child_module.get_progress()
|
||||
|
||||
@property
|
||||
def due_date(self):
|
||||
return self.child_module.due_date
|
||||
|
||||
def save_instance_data(self):
|
||||
for attribute in self.student_attributes:
|
||||
setattr(self, attribute, getattr(self.child_module, attribute))
|
||||
|
||||
def validate(self):
|
||||
"""
|
||||
Message for either error or warning validation message/s.
|
||||
|
||||
Returns message and type. Priority given to error type message.
|
||||
"""
|
||||
return self.descriptor.validate()
|
||||
|
||||
|
||||
class CombinedOpenEndedDescriptor(CombinedOpenEndedFields, RawDescriptor):
|
||||
"""
|
||||
Module for adding combined open ended questions
|
||||
"""
|
||||
mako_template = "widgets/open-ended-edit.html"
|
||||
module_class = CombinedOpenEndedModule
|
||||
|
||||
has_score = True
|
||||
always_recalculate_grades = True
|
||||
template_dir_name = "combinedopenended"
|
||||
|
||||
#Specify whether or not to pass in S3 interface
|
||||
needs_s3_interface = True
|
||||
|
||||
#Specify whether or not to pass in open ended interface
|
||||
needs_open_ended_interface = True
|
||||
|
||||
js = {'coffee': [resource_string(__name__, 'js/src/combinedopenended/edit.coffee')]}
|
||||
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,
|
||||
'enable_markdown': self.markdown is not None})
|
||||
return _context
|
||||
|
||||
@property
|
||||
def non_editable_metadata_fields(self):
|
||||
non_editable_fields = super(CombinedOpenEndedDescriptor, self).non_editable_metadata_fields
|
||||
non_editable_fields.extend([CombinedOpenEndedDescriptor.due, CombinedOpenEndedDescriptor.graceperiod,
|
||||
CombinedOpenEndedDescriptor.markdown, CombinedOpenEndedDescriptor.version])
|
||||
return non_editable_fields
|
||||
|
||||
# Proxy to CombinedOpenEndedModule so that external callers don't have to know if they're working
|
||||
# with a module or a descriptor
|
||||
child_module = module_attr('child_module')
|
||||
|
||||
def validate(self):
|
||||
"""
|
||||
Validates the state of this instance. This is the override of the general XBlock method,
|
||||
and it will also ask its superclass to validate.
|
||||
"""
|
||||
validation = super(CombinedOpenEndedDescriptor, self).validate()
|
||||
validation = StudioValidation.copy(validation)
|
||||
|
||||
i18n_service = self.runtime.service(self, "i18n")
|
||||
|
||||
validation.summary = StudioValidationMessage(
|
||||
StudioValidationMessage.ERROR,
|
||||
i18n_service.ugettext(
|
||||
"ORA1 is no longer supported. To use this assessment, "
|
||||
"replace this ORA1 component with an ORA2 component."
|
||||
)
|
||||
)
|
||||
return validation
|
||||
@@ -1,52 +1,50 @@
|
||||
"""
|
||||
MongoDB/GridFS-level code for the contentstore.
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import pymongo
|
||||
import gridfs
|
||||
from gridfs.errors import NoFile
|
||||
|
||||
from xmodule.contentstore.content import XASSET_LOCATION_TAG
|
||||
|
||||
import logging
|
||||
|
||||
from .content import StaticContent, ContentStore, StaticContentStream
|
||||
from xmodule.exceptions import NotFoundError
|
||||
from fs.osfs import OSFS
|
||||
import os
|
||||
import json
|
||||
from bson.son import SON
|
||||
|
||||
from mongodb_proxy import autoretry_read
|
||||
from opaque_keys.edx.keys import AssetKey
|
||||
from xmodule.contentstore.content import XASSET_LOCATION_TAG
|
||||
from xmodule.exceptions import NotFoundError
|
||||
from xmodule.modulestore.django import ASSET_IGNORE_REGEX
|
||||
from xmodule.util.misc import escape_invalid_characters
|
||||
from xmodule.mongo_connection import connect_to_mongodb
|
||||
from .content import StaticContent, ContentStore, StaticContentStream
|
||||
|
||||
|
||||
class MongoContentStore(ContentStore):
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def __init__(self, host, db, port=27017, user=None, password=None, bucket='fs', collection=None, **kwargs):
|
||||
"""
|
||||
MongoDB-backed ContentStore.
|
||||
"""
|
||||
# pylint: disable=unused-argument, bad-continuation
|
||||
def __init__(
|
||||
self, host, db,
|
||||
port=27017, tz_aware=True, user=None, password=None, bucket='fs', collection=None, **kwargs
|
||||
):
|
||||
"""
|
||||
Establish the connection with the mongo backend and connect to the collections
|
||||
|
||||
:param collection: ignores but provided for consistency w/ other doc_store_config patterns
|
||||
"""
|
||||
logging.debug('Using MongoDB for static content serving at host={0} port={1} db={2}'.format(host, port, db))
|
||||
|
||||
# Remove the replicaSet parameter.
|
||||
kwargs.pop('replicaSet', None)
|
||||
|
||||
_db = pymongo.database.Database(
|
||||
pymongo.MongoClient(
|
||||
host=host,
|
||||
port=port,
|
||||
document_class=dict,
|
||||
**kwargs
|
||||
),
|
||||
db
|
||||
# GridFS will throw an exception if the Database is wrapped in a MongoProxy. So don't wrap it.
|
||||
# The appropriate methods below are marked as autoretry_read - those methods will handle
|
||||
# the AutoReconnect errors.
|
||||
proxy = False
|
||||
mongo_db = connect_to_mongodb(
|
||||
db, host,
|
||||
port=port, tz_aware=tz_aware, user=user, password=password, proxy=proxy, **kwargs
|
||||
)
|
||||
|
||||
if user is not None and password is not None:
|
||||
_db.authenticate(user, password)
|
||||
self.fs = gridfs.GridFS(mongo_db, bucket) # pylint: disable=invalid-name
|
||||
|
||||
self.fs = gridfs.GridFS(_db, bucket)
|
||||
|
||||
self.fs_files = _db[bucket + ".files"] # the underlying collection GridFS uses
|
||||
self.fs_files = mongo_db[bucket + ".files"] # the underlying collection GridFS uses
|
||||
|
||||
def close_connections(self):
|
||||
"""
|
||||
@@ -86,11 +84,15 @@ class MongoContentStore(ContentStore):
|
||||
return content
|
||||
|
||||
def delete(self, location_or_id):
|
||||
"""
|
||||
Delete an asset.
|
||||
"""
|
||||
if isinstance(location_or_id, AssetKey):
|
||||
location_or_id, _ = self.asset_db_key(location_or_id)
|
||||
# Deletes of non-existent files are considered successful
|
||||
self.fs.delete(location_or_id)
|
||||
|
||||
@autoretry_read()
|
||||
def find(self, location, throw_on_not_found=True, as_stream=False):
|
||||
content_id, __ = self.asset_db_key(location)
|
||||
|
||||
@@ -206,6 +208,7 @@ class MongoContentStore(ContentStore):
|
||||
self.fs_files.remove(query)
|
||||
return assets_to_delete
|
||||
|
||||
@autoretry_read()
|
||||
def _get_all_content_for_course(self,
|
||||
course_key,
|
||||
get_thumbnails=False,
|
||||
@@ -288,6 +291,7 @@ class MongoContentStore(ContentStore):
|
||||
if not result.get('updatedExisting', True):
|
||||
raise NotFoundError(asset_db_key)
|
||||
|
||||
@autoretry_read()
|
||||
def get_attrs(self, location):
|
||||
"""
|
||||
Gets all of the attributes associated with the given asset. Note, returns even built in attrs
|
||||
|
||||
@@ -5,9 +5,10 @@ This is a place to put simple functions that operate on course metadata. It
|
||||
allows us to share code between the CourseDescriptor and CourseOverview
|
||||
classes, which both need these type of functions.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from datetime import timedelta
|
||||
from base64 import b32encode
|
||||
from datetime import datetime, timedelta
|
||||
import dateutil.parser
|
||||
from math import exp
|
||||
|
||||
from django.utils.timezone import UTC
|
||||
|
||||
@@ -222,3 +223,43 @@ def may_certify_for_course(certificates_display_behavior, certificates_show_befo
|
||||
or certificates_show_before_end
|
||||
)
|
||||
return show_early or has_ended
|
||||
|
||||
|
||||
def sorting_score(start, advertised_start, announcement):
|
||||
"""
|
||||
Returns a tuple that can be used to sort the courses according
|
||||
to how "new" they are. The "newness" score is computed using a
|
||||
heuristic that takes into account the announcement and
|
||||
(advertised) start dates of the course if available.
|
||||
|
||||
The lower the number the "newer" the course.
|
||||
"""
|
||||
# Make courses that have an announcement date have a lower
|
||||
# score than courses than don't, older courses should have a
|
||||
# higher score.
|
||||
announcement, start, now = sorting_dates(start, advertised_start, announcement)
|
||||
scale = 300.0 # about a year
|
||||
if announcement:
|
||||
days = (now - announcement).days
|
||||
score = -exp(-days / scale)
|
||||
else:
|
||||
days = (now - start).days
|
||||
score = exp(days / scale)
|
||||
return score
|
||||
|
||||
|
||||
def sorting_dates(start, advertised_start, announcement):
|
||||
"""
|
||||
Utility function to get datetime objects for dates used to
|
||||
compute the is_new flag and the sorting_score.
|
||||
"""
|
||||
try:
|
||||
start = dateutil.parser.parse(advertised_start)
|
||||
if start.tzinfo is None:
|
||||
start = start.replace(tzinfo=UTC())
|
||||
except (ValueError, AttributeError):
|
||||
start = start
|
||||
|
||||
now = datetime.now(UTC())
|
||||
|
||||
return announcement, start, now
|
||||
|
||||
@@ -3,12 +3,10 @@ Django module container for classes and operations related to the "Course Module
|
||||
"""
|
||||
import logging
|
||||
from cStringIO import StringIO
|
||||
from math import exp
|
||||
from lxml import etree
|
||||
from path import Path as path
|
||||
import requests
|
||||
from datetime import datetime
|
||||
import dateutil.parser
|
||||
from lazy import lazy
|
||||
|
||||
from xmodule import course_metadata_utils
|
||||
@@ -416,210 +414,6 @@ class CourseFields(object):
|
||||
scope=Scope.settings
|
||||
)
|
||||
has_children = True
|
||||
checklists = List(
|
||||
help=_("Checklist to Follow When Developing a Course"),
|
||||
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(
|
||||
display_name=_("Course Info Sidebar Name"),
|
||||
help=_(
|
||||
@@ -1264,7 +1058,9 @@ class CourseDescriptor(CourseFields, SequenceDescriptor, LicenseMixin):
|
||||
flag = self.is_new
|
||||
if flag is None:
|
||||
# Use a heuristic if the course has not been flagged
|
||||
announcement, start, now = self._sorting_dates()
|
||||
announcement, start, now = course_metadata_utils.sorting_dates(
|
||||
self.start, self.advertised_start, self.announcement
|
||||
)
|
||||
if announcement and (now - announcement).days < 30:
|
||||
# The course has been announced for less that month
|
||||
return True
|
||||
@@ -1284,41 +1080,11 @@ class CourseDescriptor(CourseFields, SequenceDescriptor, LicenseMixin):
|
||||
Returns a tuple that can be used to sort the courses according
|
||||
the how "new" they are. The "newness" score is computed using a
|
||||
heuristic that takes into account the announcement and
|
||||
(advertized) start dates of the course if available.
|
||||
(advertised) start dates of the course if available.
|
||||
|
||||
The lower the number the "newer" the course.
|
||||
"""
|
||||
# Make courses that have an announcement date shave a lower
|
||||
# score than courses than don't, older courses should have a
|
||||
# higher score.
|
||||
announcement, start, now = self._sorting_dates()
|
||||
scale = 300.0 # about a year
|
||||
if announcement:
|
||||
days = (now - announcement).days
|
||||
score = -exp(-days / scale)
|
||||
else:
|
||||
days = (now - start).days
|
||||
score = exp(days / scale)
|
||||
return score
|
||||
|
||||
def _sorting_dates(self):
|
||||
# utility function to get datetime objects for dates used to
|
||||
# compute the is_new flag and the sorting_score
|
||||
|
||||
announcement = self.announcement
|
||||
if announcement is not None:
|
||||
announcement = announcement
|
||||
|
||||
try:
|
||||
start = dateutil.parser.parse(self.advertised_start)
|
||||
if start.tzinfo is None:
|
||||
start = start.replace(tzinfo=UTC())
|
||||
except (ValueError, AttributeError):
|
||||
start = self.start
|
||||
|
||||
now = datetime.now(UTC())
|
||||
|
||||
return announcement, start, now
|
||||
return course_metadata_utils.sorting_score(self.start, self.advertised_start, self.announcement)
|
||||
|
||||
@lazy
|
||||
def grading_context(self):
|
||||
|
||||
@@ -23,8 +23,8 @@
|
||||
// ====================
|
||||
$annotation-yellow: rgba(255,255,10,0.3);
|
||||
$color-copy-tip: rgb(100,100,100);
|
||||
$correct: $green-d1;
|
||||
$partiallycorrect: $green-d1;
|
||||
$correct: $green-d2;
|
||||
$partiallycorrect: $green-d2;
|
||||
$incorrect: $red;
|
||||
|
||||
// +Extends - Capa
|
||||
@@ -972,7 +972,7 @@ div.problem {
|
||||
.detailed-solution {
|
||||
> p:first-child {
|
||||
@extend %t-strong;
|
||||
color: #aaa;
|
||||
color: $gray;
|
||||
text-transform: uppercase;
|
||||
font-style: normal;
|
||||
font-size: 0.9em;
|
||||
|
||||
@@ -1,993 +0,0 @@
|
||||
// lms - xmodule - combinedopenended
|
||||
// ====================
|
||||
|
||||
h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: ($baseline*0.75);
|
||||
|
||||
&.problem-header {
|
||||
section.staff {
|
||||
margin-top: ($baseline*1.5);
|
||||
font-size: 80%;
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
display: block;
|
||||
width: auto;
|
||||
border-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Problem Header
|
||||
div.name{
|
||||
padding-bottom: ($baseline*0.75);
|
||||
|
||||
h2 {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
display: inline;
|
||||
float: right;
|
||||
padding-top: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.inline-error {
|
||||
color: darken($error-color, 10%);
|
||||
}
|
||||
|
||||
section.combined-open-ended {
|
||||
@include clearfix();
|
||||
|
||||
.written-feedback {
|
||||
position: relative;
|
||||
margin: 0;
|
||||
height: 150px;
|
||||
border: 1px solid lightgray;
|
||||
padding: ($baseline/4);
|
||||
resize: vertical;
|
||||
width: 99%;
|
||||
overflow: auto;
|
||||
|
||||
.del {
|
||||
text-decoration: line-through;
|
||||
background-color: #ffc3c3;
|
||||
}
|
||||
.ins {
|
||||
background-color: #c3ffc3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
div.problemwrapper {
|
||||
border: 1px solid lightgray;
|
||||
border-radius: ($baseline/2);
|
||||
|
||||
.status-bar {
|
||||
background-color: #eee;
|
||||
border-radius: ($baseline/2) ($baseline/2) 0 0;
|
||||
border-bottom: 1px solid lightgray;
|
||||
|
||||
.statustable {
|
||||
width: 100%;
|
||||
padding: $baseline;
|
||||
}
|
||||
|
||||
.status-container {
|
||||
display: table-cell;
|
||||
text-align: center;
|
||||
|
||||
.status-elements {
|
||||
border-radius: ($baseline/4);
|
||||
border: 1px solid lightgray;
|
||||
}
|
||||
}
|
||||
|
||||
.problemtype-container {
|
||||
padding: ($baseline/2);
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
.problemtype{
|
||||
padding: ($baseline/2);
|
||||
}
|
||||
|
||||
.assessments-container {
|
||||
float: right;
|
||||
padding: ($baseline/2) $baseline ($baseline/2) ($baseline/2);
|
||||
|
||||
.assessment-text {
|
||||
display: inline-block;
|
||||
display: table-cell;
|
||||
padding-right: ($baseline/2);
|
||||
}
|
||||
}
|
||||
}
|
||||
.item-container {
|
||||
padding-bottom: ($baseline/2);
|
||||
margin: 15px;
|
||||
}
|
||||
|
||||
.result-container {
|
||||
float: left;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
section.legend-container {
|
||||
margin: 15px;
|
||||
border-radius: ($baseline/4);
|
||||
|
||||
.legenditem {
|
||||
display: inline;
|
||||
padding: ($baseline/2);
|
||||
width: 20%;
|
||||
background-color: #eee;
|
||||
font-size: .9em;
|
||||
}
|
||||
}
|
||||
|
||||
section.combined-open-ended-status {
|
||||
vertical-align: center;
|
||||
|
||||
.statusitem {
|
||||
display: table-cell;
|
||||
padding: ($baseline/2);
|
||||
width: 30px;
|
||||
border-right: 1px solid lightgray;
|
||||
background-color: #eee;
|
||||
color: #2c2c2c;
|
||||
font-size: .9em;
|
||||
|
||||
&:first-child {
|
||||
border-radius: ($baseline/4) 0 0 ($baseline/4);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-right: 0;
|
||||
border-radius: 0 ($baseline/4) ($baseline/4) 0;
|
||||
}
|
||||
|
||||
&:only-child {
|
||||
border-radius: ($baseline/4);
|
||||
}
|
||||
|
||||
.show-results {
|
||||
margin-top: .3em;
|
||||
text-align:right;
|
||||
}
|
||||
|
||||
.show-results-button {
|
||||
font: 1em monospace;
|
||||
}
|
||||
}
|
||||
|
||||
.statusitem-current {
|
||||
background-color: $white;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
span {
|
||||
&.unanswered {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
float: right;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: url('#{$static-path}/images/unanswered-icon.png') center center no-repeat;
|
||||
}
|
||||
|
||||
&.correct {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
float: right;
|
||||
width: 25px;
|
||||
height: 20px;
|
||||
background: url('#{$static-path}/images/correct-icon.png') center center no-repeat;
|
||||
}
|
||||
|
||||
&.incorrect {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
float: right;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: url('#{$static-path}/images/incorrect-icon.png') center center no-repeat;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-caret-right {
|
||||
display: inline-block;
|
||||
margin-right: ($baseline/4);
|
||||
vertical-align: baseline;
|
||||
}
|
||||
}
|
||||
|
||||
// Problem Section Controls
|
||||
|
||||
.visibility-control, .visibility-control-prompt {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
|
||||
.inner {
|
||||
float: left;
|
||||
margin-top: $baseline;
|
||||
width: 85%;
|
||||
height: 5px;
|
||||
border-top: 1px dotted #ddd;
|
||||
}
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: block;
|
||||
float: right;
|
||||
padding-top: ($baseline/2);
|
||||
width: 15%;
|
||||
text-align: center;
|
||||
font-size: .9em;
|
||||
}
|
||||
|
||||
// Rubric Styling
|
||||
|
||||
.wrapper-score-selection {
|
||||
display: table-cell;
|
||||
padding: 0 ($baseline/2);
|
||||
width: 20px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.wrappable {
|
||||
display: table-cell;
|
||||
padding: ($baseline/4);
|
||||
}
|
||||
|
||||
.rubric-list-item {
|
||||
margin-bottom: ($baseline/10);
|
||||
padding: ($baseline/2);
|
||||
|
||||
&:hover, &:focus {
|
||||
background-color: #eee;
|
||||
}
|
||||
.rubric-label-selected{
|
||||
border-radius: ($baseline/4);
|
||||
background-color: #eee;
|
||||
}
|
||||
}
|
||||
|
||||
span.rubric-category {
|
||||
display: block;
|
||||
margin-bottom: ($baseline/2);
|
||||
padding-top: ($baseline/2);
|
||||
width: 100%;
|
||||
border-bottom: 1px solid lightgray;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
div.combined-rubric-container {
|
||||
margin: 15px;
|
||||
padding-top: ($baseline/2);
|
||||
padding-bottom: ($baseline/4);
|
||||
|
||||
ul.rubric-list {
|
||||
margin: 0 $baseline ($baseline/2) $baseline;
|
||||
padding: 0;
|
||||
list-style-type: none;
|
||||
|
||||
li {
|
||||
|
||||
&.rubric-list-item {
|
||||
margin-bottom: ($baseline/10);
|
||||
padding: ($baseline/2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
h4 {
|
||||
padding-top: ($baseline/2);
|
||||
}
|
||||
|
||||
span.rubric-category {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border-bottom: 1px solid lightgray;
|
||||
font-weight: bold;
|
||||
font-size: .9em;
|
||||
}
|
||||
|
||||
label.choicegroup_correct {
|
||||
&:before {
|
||||
margin-right: ($baseline*0.75);
|
||||
content: url('#{$static-path}/images/correct-icon.png');
|
||||
}
|
||||
}
|
||||
|
||||
label.choicegroup_partialcorrect {
|
||||
&:before {
|
||||
margin-right: ($baseline*0.75);
|
||||
content: url('#{$static-path}/images/partially-correct-icon.png');
|
||||
}
|
||||
}
|
||||
|
||||
label.choicegroup_incorrect {
|
||||
&:before {
|
||||
margin-right: ($baseline*0.75);
|
||||
content: url('#{$static-path}/images/incorrect-icon.png');
|
||||
}
|
||||
}
|
||||
|
||||
div.written-feedback {
|
||||
background: $gray-l6;
|
||||
padding: ($baseline/4);
|
||||
}
|
||||
}
|
||||
|
||||
div.result-container {
|
||||
padding-top: ($baseline/2);
|
||||
padding-bottom: ($baseline/4);
|
||||
|
||||
.evaluation {
|
||||
p {
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.feedback-on-feedback {
|
||||
height: 100px;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.evaluation-response {
|
||||
margin-bottom: ($baseline/10);
|
||||
|
||||
header {
|
||||
a {
|
||||
font-size: .85em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.evaluation-scoring {
|
||||
.scoring-list {
|
||||
margin-left: 3px;
|
||||
list-style-type: none;
|
||||
|
||||
li {
|
||||
display:inline;
|
||||
margin-left: 0;
|
||||
|
||||
&:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: .9em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.submit-message-container {
|
||||
margin: ($baseline/2) 0;
|
||||
}
|
||||
|
||||
.external-grader-message {
|
||||
margin-bottom: ($baseline/4);
|
||||
|
||||
section {
|
||||
padding-left: $baseline;
|
||||
background-color: #fafafa;
|
||||
color: #2c2c2c;
|
||||
font-family: monospace;
|
||||
font-size: 1em;
|
||||
padding-top: ($baseline/2);
|
||||
padding-bottom: 30px;
|
||||
|
||||
header {
|
||||
font-size: 1.4em;
|
||||
}
|
||||
|
||||
.shortform {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.longform {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
.result-errors {
|
||||
margin: ($baseline/4);
|
||||
padding: ($baseline/2) ($baseline/2) ($baseline/2) ($baseline*2);
|
||||
background: url('#{$static-path}/images/incorrect-icon.png') center left no-repeat;
|
||||
|
||||
li {
|
||||
color: #B00;
|
||||
}
|
||||
}
|
||||
|
||||
.result-output {
|
||||
margin: ($baseline/4);
|
||||
padding: $baseline 0 ($baseline*0.75) ($baseline*2.5);
|
||||
border-top: 1px solid #ddd;
|
||||
border-left: 20px solid #fafafa;
|
||||
|
||||
h4 {
|
||||
font-size: 1em;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
dl {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
dt {
|
||||
margin-top: $baseline;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin-left: 24pt;
|
||||
}
|
||||
}
|
||||
|
||||
.markup-text{
|
||||
margin: ($baseline/4);
|
||||
padding: $baseline 0 ($baseline*0.75) ($baseline*2.5);
|
||||
border-top: 1px solid #ddd;
|
||||
border-left: 20px solid #fafafa;
|
||||
|
||||
bs {
|
||||
color: #bb0000;
|
||||
}
|
||||
|
||||
bg {
|
||||
color: #bda046;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rubric-result-container {
|
||||
padding: ($baseline/10);
|
||||
margin: 0;
|
||||
display: inline;
|
||||
|
||||
.rubric-result {
|
||||
font-size: .9em;
|
||||
padding: ($baseline/10);
|
||||
display: inline-table;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div.rubric {
|
||||
ul.rubric-list{
|
||||
margin: 0 $baseline ($baseline/2) $baseline;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
list-style-type: none;
|
||||
|
||||
li {
|
||||
&.rubric-list-item {
|
||||
margin-bottom: ($baseline/10);
|
||||
padding: ($baseline/2);
|
||||
border-radius: ($baseline/4);
|
||||
|
||||
&:hover, &:focus {
|
||||
background-color: #eee;
|
||||
}
|
||||
|
||||
.wrapper-score-selection {
|
||||
display: table-cell;
|
||||
padding: 0 ($baseline/2);
|
||||
width: 20px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.wrappable {
|
||||
display: table-cell;
|
||||
padding: ($baseline/4);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
span.rubric-category {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border-bottom: 1px solid lightgray;
|
||||
font-weight: bold;
|
||||
font-size: .9em;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
section.open-ended-child {
|
||||
@media print {
|
||||
display: block;
|
||||
padding: 0;
|
||||
width: auto;
|
||||
|
||||
canvas, img {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
}
|
||||
|
||||
.inline {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
ol.enumerate {
|
||||
li {
|
||||
&:before {
|
||||
display: block;
|
||||
visibility: hidden;
|
||||
height: 0;
|
||||
content: " ";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.solution-span {
|
||||
> span {
|
||||
position: relative;
|
||||
display: block;
|
||||
margin: $baseline 0;
|
||||
padding: 9px 15px $baseline;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 3px;
|
||||
background: $white;
|
||||
box-shadow: inset 0 0 0 1px #eee;
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
&.answer {
|
||||
margin-top: -2px;
|
||||
}
|
||||
&.status {
|
||||
margin: 8px 0 0 ($baseline/2);
|
||||
text-indent: -9999px;
|
||||
}
|
||||
}
|
||||
|
||||
div.unanswered {
|
||||
p.status {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: url('#{$static-path}/images/unanswered-icon.png') center center no-repeat;
|
||||
}
|
||||
}
|
||||
|
||||
div.correct, div.ui-icon-check {
|
||||
p.status {
|
||||
display: inline-block;
|
||||
width: 25px;
|
||||
height: 20px;
|
||||
background: url('#{$static-path}/images/correct-icon.png') center center no-repeat;
|
||||
}
|
||||
|
||||
input {
|
||||
border-color: green;
|
||||
}
|
||||
}
|
||||
|
||||
div.processing {
|
||||
p.status {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: url('#{$static-path}/images/spinner.gif') center center no-repeat;
|
||||
}
|
||||
|
||||
input {
|
||||
border-color: #aaa;
|
||||
}
|
||||
}
|
||||
|
||||
div.incorrect, div.ui-icon-close {
|
||||
p.status {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: url('#{$static-path}/images/incorrect-icon.png') center center no-repeat;
|
||||
text-indent: -9999px;
|
||||
}
|
||||
|
||||
input {
|
||||
border-color: red;
|
||||
}
|
||||
}
|
||||
|
||||
> span {
|
||||
display: block;
|
||||
margin-bottom: lh(0.5);
|
||||
}
|
||||
|
||||
p.answer {
|
||||
display: inline-block;
|
||||
margin-bottom: 0;
|
||||
margin-left: ($baseline/2);
|
||||
|
||||
&:before {
|
||||
content: "Answer: ";
|
||||
font-weight: bold;
|
||||
display: inline;
|
||||
|
||||
}
|
||||
&:empty {
|
||||
&:before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
&.unanswered, &.ui-icon-bullet {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
top: 4px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: url('#{$static-path}/images/unanswered-icon.png') center center no-repeat;
|
||||
}
|
||||
|
||||
&.processing, &.ui-icon-processing {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
top: 6px;
|
||||
width: 25px;
|
||||
height: 20px;
|
||||
background: url('#{$static-path}/images/spinner.gif') center center no-repeat;
|
||||
}
|
||||
|
||||
&.correct, &.ui-icon-check {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
top: 6px;
|
||||
width: 25px;
|
||||
height: 20px;
|
||||
background: url('#{$static-path}/images/correct-icon.png') center center no-repeat;
|
||||
}
|
||||
|
||||
&.incorrect, &.ui-icon-close {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
top: 6px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: url('#{$static-path}/images/incorrect-icon.png') center center no-repeat;
|
||||
}
|
||||
}
|
||||
|
||||
.reload {
|
||||
float:right;
|
||||
margin: ($baseline/2);
|
||||
}
|
||||
|
||||
div.short-form-response {
|
||||
@include clearfix();
|
||||
overflow-y: auto;
|
||||
margin-bottom: 0;
|
||||
padding: ($baseline/2);
|
||||
min-height: 20px;
|
||||
height: auto;
|
||||
border: 1px solid #ddd;
|
||||
background: $gray-l6;
|
||||
}
|
||||
|
||||
.grader-status {
|
||||
@include clearfix();
|
||||
margin: ($baseline/2) 0;
|
||||
padding: ($baseline/2);
|
||||
border-radius: 5px;
|
||||
background: $gray-l6;
|
||||
|
||||
span {
|
||||
display: block;
|
||||
float: left;
|
||||
overflow: hidden;
|
||||
margin: -7px 7px 0 0;
|
||||
text-indent: -9999px;
|
||||
}
|
||||
|
||||
.grading {
|
||||
margin: 0 7px 0 0;
|
||||
padding-left: 25px;
|
||||
background: url('#{$static-path}/images/info-icon.png') left center no-repeat;
|
||||
text-indent: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
float: left;
|
||||
margin-bottom: 0;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
&.file {
|
||||
margin-top: $baseline;
|
||||
padding: $baseline 0 0 0;
|
||||
border: 0;
|
||||
border-top: 1px solid #eee;
|
||||
background: $white;
|
||||
|
||||
p.debug {
|
||||
display: none;
|
||||
}
|
||||
|
||||
input {
|
||||
float: left;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
form.option-input {
|
||||
margin: -($baseline/2) 0 $baseline;
|
||||
padding-bottom: $baseline;
|
||||
|
||||
select {
|
||||
margin-right: flex-gutter();
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
margin-bottom: lh();
|
||||
margin-left: 0.75em;
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
|
||||
ul.rubric-list{
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style-type: none;
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
&.rubric-list-item {
|
||||
margin-bottom: 0;
|
||||
padding: 0;
|
||||
border-radius: ($baseline/4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ol {
|
||||
margin-bottom: lh();
|
||||
margin-left: .75em;
|
||||
margin-left: .75rem;
|
||||
list-style: decimal outside none;
|
||||
}
|
||||
|
||||
dl {
|
||||
line-height: 1.4em;
|
||||
}
|
||||
|
||||
dl dt {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
dl dd {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin-left: .5em;
|
||||
margin-left: .5rem;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 0;
|
||||
padding: 0;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: lh();
|
||||
}
|
||||
|
||||
hr {
|
||||
float: none;
|
||||
clear: both;
|
||||
margin: 0 0 .75rem;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
border: none;
|
||||
background: #ddd;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
#{$all-text-inputs} {
|
||||
display: inline;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
div.action {
|
||||
margin-top: $baseline;
|
||||
|
||||
input.save {
|
||||
@extend .blue-button !optional;
|
||||
}
|
||||
|
||||
.submission_feedback {
|
||||
display: inline-block;
|
||||
margin: 8px 0 0 ($baseline/2);
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
}
|
||||
|
||||
.detailed-solution {
|
||||
> p:first-child {
|
||||
color: #aaa;
|
||||
text-transform: uppercase;
|
||||
font-weight: bold;
|
||||
font-style: normal;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
div.open-ended-alert,
|
||||
.save_message {
|
||||
margin-top: ($baseline/2);
|
||||
margin-bottom: ($baseline/4);
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ebe8bf;
|
||||
border-radius: 3px;
|
||||
background: #fffcdd;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
div.capa_reset {
|
||||
margin-top: ($baseline/2);
|
||||
margin-bottom: ($baseline/2);
|
||||
padding: 25px;
|
||||
border: 1px solid $error-color;
|
||||
border-radius: 3px;
|
||||
background-color: lighten($error-color, 25%);
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.capa_reset > h2 {
|
||||
color: #aa0000;
|
||||
}
|
||||
|
||||
.capa_reset li {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.assessment-container {
|
||||
margin: ($baseline*2) 0 ($baseline*1.5) 0;
|
||||
|
||||
.scoring-container {
|
||||
p {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
label {
|
||||
display: inline-block;
|
||||
margin: ($baseline/2);
|
||||
padding: ($baseline/4);
|
||||
min-width: 50px;
|
||||
background-color: $gray-l3;
|
||||
text-size: 1.5em;
|
||||
}
|
||||
|
||||
input[type=radio]:checked + label {
|
||||
background: #666;
|
||||
color: white;
|
||||
}
|
||||
|
||||
input[class='grade-selection'] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div.prompt {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
h4 {
|
||||
padding: $baseline/2 0;
|
||||
}
|
||||
}
|
||||
|
||||
//OE Tool Area Styling
|
||||
|
||||
.oe-tools {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
border-radius: 5px;
|
||||
|
||||
.oe-tools-label, .oe-tools-scores-label {
|
||||
display: inline-block;
|
||||
padding: $baseline/2;
|
||||
vertical-align: middle;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.rubric-button {
|
||||
padding: 8px $baseline/4;
|
||||
}
|
||||
|
||||
.rubric-previous-button {
|
||||
margin-right: $baseline/4;
|
||||
}
|
||||
|
||||
.rubric-next-button {
|
||||
margin-left: $baseline/4;
|
||||
}
|
||||
|
||||
.next-step-button {
|
||||
margin: $baseline/2;
|
||||
}
|
||||
.reset-button {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
// Staff Grading
|
||||
.problem-list-container {
|
||||
margin: $baseline/2;
|
||||
|
||||
.instructions {
|
||||
padding-bottom: $baseline/2;
|
||||
}
|
||||
}
|
||||
|
||||
.staff-grading {
|
||||
|
||||
.breadcrumbs {
|
||||
padding: ($baseline/10);
|
||||
background-color: $gray-l6;
|
||||
border-radius: 5px;
|
||||
margin-bottom: ($baseline/2);
|
||||
}
|
||||
|
||||
.prompt-wrapper {
|
||||
padding-top: ($baseline/2);
|
||||
|
||||
.meta-info-wrapper {
|
||||
padding: ($baseline/2);
|
||||
border-radius: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
section.peer-grading-container{
|
||||
div.peer-grading{
|
||||
section.calibration-feedback {
|
||||
padding: $baseline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div.staff-info{
|
||||
background-color: #eee;
|
||||
border-radius: 10px;
|
||||
border-bottom: 1px solid lightgray;
|
||||
padding: ($baseline/2);
|
||||
margin: ($baseline/2) 0 ($baseline/2) 0;
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
.editor-bar {
|
||||
|
||||
.editor-tabs {
|
||||
|
||||
.advanced-toggle {
|
||||
@include white-button;
|
||||
height: auto;
|
||||
margin-top: -1px;
|
||||
padding: 3px 9px;
|
||||
font-size: 12px;
|
||||
|
||||
&.current {
|
||||
border: 1px solid $lightGrey !important;
|
||||
border-radius: 3px !important;
|
||||
background: $lightGrey !important;
|
||||
color: $darkGrey !important;
|
||||
pointer-events: none;
|
||||
cursor: none;
|
||||
|
||||
&:hover, &:focus {
|
||||
box-shadow: 0 0 0 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cheatsheet-toggle {
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
padding: 0;
|
||||
margin: 0 ($baseline/4) 0 ($baseline*0.75);
|
||||
border-radius: 22px;
|
||||
border: 1px solid #a5aaaf;
|
||||
background: #e5ecf3;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #565d64;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.simple-editor-open-ended-cheatsheet {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 100%;
|
||||
width: 0;
|
||||
border-radius: 0 3px 3px 0;
|
||||
@include linear-gradient(left, $shadow-l1, $transparent 4px);
|
||||
background-color: $white;
|
||||
overflow: hidden;
|
||||
@include transition(width .3s linear 0s);
|
||||
|
||||
&.shown {
|
||||
width: 20%;
|
||||
height: 100%;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.cheatsheet-wrapper {
|
||||
padding: 10%;
|
||||
}
|
||||
|
||||
h6 {
|
||||
margin-bottom: 7px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.row {
|
||||
@include clearfix();
|
||||
padding-bottom: 5px !important;
|
||||
margin-bottom: 10px !important;
|
||||
border-bottom: 1px solid #ddd !important;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none !important;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.col {
|
||||
float: left;
|
||||
|
||||
&.sample {
|
||||
width: 60px;
|
||||
margin-right: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
pre {
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
code {
|
||||
padding: 0;
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
|
||||
.combinedopenended-editor-icon {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
color: #333;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
$leaderboard: #F4F4F4;
|
||||
|
||||
section.foldit {
|
||||
div.folditchallenge {
|
||||
table {
|
||||
border: 1px solid lighten($leaderboard, 10%);
|
||||
border-collapse: collapse;
|
||||
margin-top: $baseline;
|
||||
}
|
||||
th {
|
||||
background: $leaderboard;
|
||||
color: darken($leaderboard, 25%);
|
||||
}
|
||||
td {
|
||||
background: lighten($leaderboard, 3%);
|
||||
border-bottom: 1px solid $white;
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,7 +91,7 @@ ul {
|
||||
|
||||
a {
|
||||
&:link, &:visited, &:hover, &:active, &:focus {
|
||||
color: #1d9dd9;
|
||||
color: $blue;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
import logging
|
||||
from lxml import etree
|
||||
|
||||
from pkg_resources import resource_string
|
||||
|
||||
from xmodule.editing_module import EditingDescriptor
|
||||
from xmodule.x_module import XModule
|
||||
from xmodule.xml_module import XmlDescriptor
|
||||
from xblock.fields import Scope, Integer, String
|
||||
from .fields import Date
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FolditFields(object):
|
||||
# default to what Spring_7012x uses
|
||||
required_level_half_credit = Integer(default=3, scope=Scope.settings)
|
||||
required_sublevel_half_credit = Integer(default=5, scope=Scope.settings)
|
||||
required_level = Integer(default=4, scope=Scope.settings)
|
||||
required_sublevel = Integer(default=5, scope=Scope.settings)
|
||||
due = Date(help="Date that this problem is due by", scope=Scope.settings)
|
||||
|
||||
show_basic_score = String(scope=Scope.settings, default='false')
|
||||
show_leaderboard = String(scope=Scope.settings, default='false')
|
||||
|
||||
|
||||
class FolditModule(FolditFields, XModule):
|
||||
|
||||
css = {'scss': [resource_string(__name__, 'css/foldit/leaderboard.scss')]}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""
|
||||
Example:
|
||||
<foldit show_basic_score="true"
|
||||
required_level="4"
|
||||
required_sublevel="3"
|
||||
required_level_half_credit="2"
|
||||
required_sublevel_half_credit="3"
|
||||
show_leaderboard="false"/>
|
||||
"""
|
||||
super(FolditModule, self).__init__(*args, **kwargs)
|
||||
self.due_time = self.due
|
||||
|
||||
def is_complete(self):
|
||||
"""
|
||||
Did the user get to the required level before the due date?
|
||||
"""
|
||||
# We normally don't want django dependencies in xmodule. foldit is
|
||||
# special. Import this late to avoid errors with things not yet being
|
||||
# initialized.
|
||||
from foldit.models import PuzzleComplete
|
||||
|
||||
complete = PuzzleComplete.is_level_complete(
|
||||
self.system.anonymous_student_id,
|
||||
self.required_level,
|
||||
self.required_sublevel,
|
||||
self.due_time)
|
||||
return complete
|
||||
|
||||
def is_half_complete(self):
|
||||
"""
|
||||
Did the user reach the required level for half credit?
|
||||
|
||||
Ideally this would be more flexible than just 0, 0.5, or 1 credit. On
|
||||
the other hand, the xml attributes for specifying more specific
|
||||
cut-offs and partial grades can get more confusing.
|
||||
"""
|
||||
from foldit.models import PuzzleComplete
|
||||
complete = PuzzleComplete.is_level_complete(
|
||||
self.system.anonymous_student_id,
|
||||
self.required_level_half_credit,
|
||||
self.required_sublevel_half_credit,
|
||||
self.due_time)
|
||||
return complete
|
||||
|
||||
def completed_puzzles(self):
|
||||
"""
|
||||
Return a list of puzzles that this user has completed, as an array of
|
||||
dicts:
|
||||
|
||||
[ {'set': int,
|
||||
'subset': int,
|
||||
'created': datetime} ]
|
||||
|
||||
The list is sorted by set, then subset
|
||||
"""
|
||||
from foldit.models import PuzzleComplete
|
||||
|
||||
return sorted(
|
||||
PuzzleComplete.completed_puzzles(self.system.anonymous_student_id),
|
||||
key=lambda d: (d['set'], d['subset']))
|
||||
|
||||
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
|
||||
|
||||
if courses is None:
|
||||
courses = [self.location.course_key]
|
||||
|
||||
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
|
||||
|
||||
def get_html(self):
|
||||
"""
|
||||
Render the html for the module.
|
||||
"""
|
||||
goal_level = '{0}-{1}'.format(
|
||||
self.required_level,
|
||||
self.required_sublevel)
|
||||
|
||||
showbasic = (self.show_basic_score.lower() == "true")
|
||||
showleader = (self.show_leaderboard.lower() == "true")
|
||||
|
||||
context = {
|
||||
'due': self.due,
|
||||
'success': self.is_complete(),
|
||||
'goal_level': goal_level,
|
||||
'completed': self.completed_puzzles(),
|
||||
'top_scores': self.puzzle_leaders(),
|
||||
'show_basic': showbasic,
|
||||
'show_leader': showleader,
|
||||
'folditbasic': self.get_basicpuzzles_html(),
|
||||
'folditchallenge': self.get_challenge_html()
|
||||
}
|
||||
|
||||
return self.system.render_template('foldit.html', context)
|
||||
|
||||
def get_basicpuzzles_html(self):
|
||||
"""
|
||||
Render html for the basic puzzle section.
|
||||
"""
|
||||
goal_level = '{0}-{1}'.format(
|
||||
self.required_level,
|
||||
self.required_sublevel)
|
||||
|
||||
context = {
|
||||
'due': self.due,
|
||||
'success': self.is_complete(),
|
||||
'goal_level': goal_level,
|
||||
'completed': self.completed_puzzles(),
|
||||
}
|
||||
return self.system.render_template('folditbasic.html', context)
|
||||
|
||||
def get_challenge_html(self):
|
||||
"""
|
||||
Render html for challenge (i.e., the leaderboard)
|
||||
"""
|
||||
|
||||
context = {
|
||||
'top_scores': self.puzzle_leaders()}
|
||||
|
||||
return self.system.render_template('folditchallenge.html', context)
|
||||
|
||||
def get_score(self):
|
||||
"""
|
||||
0 if required_level_half_credit - required_sublevel_half_credit not
|
||||
reached.
|
||||
0.5 if required_level_half_credit and required_sublevel_half_credit
|
||||
reached.
|
||||
1 if requred_level and required_sublevel reached.
|
||||
"""
|
||||
if self.is_complete():
|
||||
score = 1
|
||||
elif self.is_half_complete():
|
||||
score = 0.5
|
||||
else:
|
||||
score = 0
|
||||
return {'score': score,
|
||||
'total': self.max_score()}
|
||||
|
||||
def max_score(self):
|
||||
return 1
|
||||
|
||||
|
||||
class FolditDescriptor(FolditFields, XmlDescriptor, EditingDescriptor):
|
||||
"""
|
||||
Module for adding Foldit problems to courses
|
||||
"""
|
||||
mako_template = "widgets/html-edit.html"
|
||||
module_class = FolditModule
|
||||
filename_extension = "xml"
|
||||
|
||||
has_score = True
|
||||
|
||||
show_in_read_only_mode = True
|
||||
|
||||
js = {'coffee': [resource_string(__name__, 'js/src/html/edit.coffee')]}
|
||||
js_module_name = "HTMLEditingDescriptor"
|
||||
|
||||
# The grade changes without any student interaction with the edx website,
|
||||
# so always need to actually check.
|
||||
always_recalculate_grades = True
|
||||
|
||||
@classmethod
|
||||
def definition_from_xml(cls, xml_object, system):
|
||||
return {}, []
|
||||
|
||||
def definition_to_xml(self, resource_fs):
|
||||
xml_object = etree.Element('foldit')
|
||||
return xml_object
|
||||
@@ -1,128 +0,0 @@
|
||||
<section class="course-content">
|
||||
<section class="xblock xblock-student_view xmodule_display xmodule_CombinedOpenEndedModule" data-type="CombinedOpenEnded">
|
||||
<section id="combined-open-ended" class="combined-open-ended" data-ajax-url="/courses/MITx/6.002x/2012_Fall/modx/i4x://MITx/6.002x/combinedopenended/CombinedOE" data-allow_reset="False" data-state="assessing" data-task-count="2" data-task-number="1">
|
||||
<h2>Problem 1</h2>
|
||||
<div class="status-container">
|
||||
<h4>Status</h4>
|
||||
<div class="status-elements">
|
||||
<section id="combined-open-ended-status" class="combined-open-ended-status">
|
||||
<div class="statusitem" data-status-number="0">
|
||||
Step 1 (Problem complete) : 1 / 1
|
||||
<span class="correct" id="status"></span>
|
||||
</div>
|
||||
<div class="statusitem statusitem-current" data-status-number="1">
|
||||
Step 2 (Being scored) : None / 1
|
||||
<span class="grading" id="status"></span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item-container">
|
||||
<h4>Problem</h4>
|
||||
<div class="problem-container">
|
||||
<div class="item">
|
||||
<section id="openended_open_ended" class="open-ended-child" data-state="assessing" data-child-type="openended">
|
||||
<div class="error">
|
||||
</div>
|
||||
<div class="prompt">
|
||||
Some prompt.
|
||||
</div>
|
||||
<textarea rows="30" cols="80" name="answer" class="answer short-form-response" id="input_open_ended" disabled="disabled">
|
||||
Test submission. Yaaaaaaay!
|
||||
</textarea>
|
||||
<div class="message-wrapper"></div>
|
||||
<div class="grader-status">
|
||||
<span class="grading" id="status_open_ended">Submitted for grading.</span>
|
||||
</div>
|
||||
<input type="button" value="Submit assessment" class="submit-button" name="show" style="display: none;">
|
||||
<input name="skip" class="skip-button" type="button" value="Skip Post-Assessment" style="display: none;">
|
||||
<div class="open-ended-action"></div>
|
||||
<span id="answer_open_ended"></span>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<div class="oe-tools response-tools">
|
||||
<span class="oe-tools-label"></span>
|
||||
<input type="button" value="Reset" class="reset-button" name="reset" style="display: none;">
|
||||
</div>
|
||||
<input type="button" value="Next Step" class="next-step-button" name="reset" style="display: none;">
|
||||
</div>
|
||||
<a name="results">
|
||||
<div class="result-container">
|
||||
</div>
|
||||
</a>
|
||||
</section>
|
||||
<a name="results">
|
||||
</a>
|
||||
</section>
|
||||
<a name="results">
|
||||
</a>
|
||||
<div>
|
||||
<a name="results">
|
||||
</a>
|
||||
<a href="https://github.com/MITx/content-mit-6002x/tree/master/combinedopenended/CombinedOE.xml">
|
||||
Edit
|
||||
</a> /
|
||||
<a href="#i4x_MITx_6_002x_combinedopenended_CombinedOE_xqa-modal" onclick="javascript:getlog('i4x_MITx_6_002x_combinedopenended_CombinedOE', {
|
||||
'location': 'i4x://MITx/6.002x/combinedopenended/CombinedOE',
|
||||
'xqa_key': 'KUBrWtK3RAaBALLbccHrXeD3RHOpmZ2A',
|
||||
'category': 'CombinedOpenEndedModule',
|
||||
'user': 'blah'
|
||||
})" id="i4x_MITx_6_002x_combinedopenended_CombinedOE_xqa_log">QA</a>
|
||||
</div>
|
||||
<div>
|
||||
<a href="#i4x_MITx_6_002x_combinedopenended_CombinedOE_debug" id="i4x_MITx_6_002x_combinedopenended_CombinedOE_trig">
|
||||
Staff Debug Info
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<section id="i4x_MITx_6_002x_combinedopenended_CombinedOE_xqa-modal" class="modal xqa-modal" style="width:80%; left:20%; height:80%; overflow:auto">
|
||||
<div class="inner-wrapper">
|
||||
<header>
|
||||
<h2>edX Content Quality Assessment</h2>
|
||||
</header>
|
||||
|
||||
<form id="i4x_MITx_6_002x_combinedopenended_CombinedOE_xqa_form" class="xqa_form">
|
||||
<label>Comment</label>
|
||||
<input id="i4x_MITx_6_002x_combinedopenended_CombinedOE_xqa_entry" type="text" placeholder="comment">
|
||||
<label>Tag</label>
|
||||
<span style="color:black;vertical-align: -10pt">Optional tag (eg "done" or "broken"): </span>
|
||||
<input id="i4x_MITx_6_002x_combinedopenended_CombinedOE_xqa_tag" type="text" placeholder="tag" style="width:80px;display:inline">
|
||||
<div class="submit">
|
||||
<button name="submit" type="submit">Add comment</button>
|
||||
</div>
|
||||
<hr>
|
||||
<div id="i4x_MITx_6_002x_combinedopenended_CombinedOE_xqa_log_data"></div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="modal staff-modal" id="i4x_MITx_6_002x_combinedopenended_CombinedOE_debug" style="width:80%; left:20%; height:80%; overflow:auto;">
|
||||
<div class="inner-wrapper" style="color:black">
|
||||
<header>
|
||||
<h2>Staff Debug</h2>
|
||||
</header>
|
||||
<div class="staff_info" style="display:block">
|
||||
is_released = <font color="red">Yes!</font>
|
||||
location = i4x://MITx/6.002x/combinedopenended/CombinedOE
|
||||
github = <a href="https://github.com/MITx/content-mit-6002x/tree/master/combinedopenended/CombinedOE.xml">https://github.com/MITx/content-mit-6002x/tree/master/combinedopenended/CombinedOE.xml</a>
|
||||
definition = <pre>None</pre>
|
||||
metadata = {
|
||||
"showanswer": "attempted",
|
||||
"display_name": "Problem 1",
|
||||
"graceperiod": "1 day 12 hours 59 minutes 59 seconds",
|
||||
"xqa_key": "KUBrWtK3RAaBALLbccHrXeD3RHOpmZ2A",
|
||||
"rerandomize": "never",
|
||||
"start": "2012-09-05T12:00",
|
||||
"attempts": "10000",
|
||||
"data_dir": "content-mit-6002x",
|
||||
"max_score": "1"
|
||||
}
|
||||
category = CombinedOpenEndedModule
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="i4x_MITx_6_002x_combinedopenended_CombinedOE_setup"></div>
|
||||
</section>
|
||||
@@ -1,6 +0,0 @@
|
||||
<section class="combinedopenended-editor editor">
|
||||
<div class="row">
|
||||
<textarea class="markdown-box">markdown</textarea>
|
||||
<textarea class="xml-box" rows="8" cols="40">xml</textarea>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,5 +0,0 @@
|
||||
<section class="combinedopenended-editor editor">
|
||||
<div class="row">
|
||||
<textarea class="xml-box" rows="8" cols="40">xml only</textarea>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,321 +0,0 @@
|
||||
<section id="combined-open-ended" class="combined-open-ended" data-location="i4x://test/2323/combinedopenended/b893eedec151441f8644187266ccce00" data-ajax-url="/courses/test/2323/Test2/modx/i4x://test/2323/combinedopenended/b893eedec151441f8644187266ccce00" data-allow_reset="False" data-state="initial" data-task-count="1" data-task-number="1" data-accept-file-upload="False">
|
||||
<div class="name">
|
||||
<h2>Open Response Assessment</h2>
|
||||
<div class="progress-container">
|
||||
</div>
|
||||
</div>
|
||||
<div class="problemwrapper">
|
||||
<div class="status-bar">
|
||||
<table class="statustable">
|
||||
<tbody><tr>
|
||||
<td class="problemtype-container">
|
||||
<div class="problemtype">
|
||||
Open Response
|
||||
</div>
|
||||
</td>
|
||||
<td class="assessments-container">
|
||||
<div class="assessment-text">
|
||||
Assessments:
|
||||
</div>
|
||||
<div class="status-container">
|
||||
|
||||
<div class="status-elements">
|
||||
<section id="combined-open-ended-status" class="combined-open-ended-status">
|
||||
|
||||
<div class="statusitem statusitem-current" data-status-number="0">
|
||||
Peer
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
</div>
|
||||
|
||||
<div class="item-container">
|
||||
<div class="visibility-control visibility-control-prompt">
|
||||
<div class="inner">
|
||||
</div>
|
||||
<a href="" class="question-header">Show Question</a>
|
||||
</div>
|
||||
<div class="problem-container">
|
||||
<div class="item">
|
||||
<section id="openended_open_ended" class="open-ended-child" data-state="post_assessment" data-child-type="openended">
|
||||
<div class="error"></div>
|
||||
<div class="prompt open" style="display: none;">
|
||||
<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 views 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>
|
||||
</div>
|
||||
<div class="visibility-control visibility-control-response">
|
||||
<div class="inner">
|
||||
</div>
|
||||
<span class="section-header section-header-response">Response</span>
|
||||
</div>
|
||||
<div class="answer short-form-response" id="input_open_ended"></div>
|
||||
|
||||
<div class="message-wrapper"></div>
|
||||
<div class="grader-status">
|
||||
|
||||
</div>
|
||||
|
||||
<div class="file-upload"></div>
|
||||
|
||||
<input type="button" value="Submit post-assessment" class="submit-button" name="show" style="display: none;">
|
||||
<input name="skip" class="skip-button" type="button" value="Skip Post-Assessment" style="display: none;">
|
||||
|
||||
<div class="open-ended-action"></div>
|
||||
|
||||
<span id="answer_open_ended"></span>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<div class="oe-tools response-tools">
|
||||
<span class="oe-tools-label"></span>
|
||||
<input type="button" value="Reset" class="reset-button" name="reset" style="display: inline-block;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="combined-rubric-container" data-status="shown" data-number="0" style="">
|
||||
<div class="visibility-control visibility-control-rubric">
|
||||
<div class="inner">
|
||||
</div>
|
||||
<span class="section-header section-header-rubric">Submitted Rubric</span>
|
||||
</div>
|
||||
<div class="rubric-header">
|
||||
<button class="rubric-collapse" href="#">Show Full Rubric</button>
|
||||
Scored rubric from grader 1
|
||||
</div>
|
||||
<div class="rubric">
|
||||
|
||||
<span class="rubric-category">
|
||||
Ideas
|
||||
</span> <br>
|
||||
<ul class="rubric-list">
|
||||
|
||||
<li class="rubric-list-item">
|
||||
<div class="rubric-label">
|
||||
<label class="choicegroup_incorrect wrapper-score-selection"></label>
|
||||
<span class="wrappable"> 0 points :
|
||||
Difficult for the reader to discern the main idea. Too brief or too repetitive to establish or maintain a focus.
|
||||
</span>
|
||||
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="rubric-list-item rubric-info-item" style="display: none;">
|
||||
<div class="rubric-label">
|
||||
<label class="rubric-elements-info">
|
||||
<span class="wrapper-score-selection"> </span>
|
||||
<span class="wrappable"> 1 points :
|
||||
Attempts a main idea. Sometimes loses focus or ineffectively displays focus.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="rubric-list-item rubric-info-item" style="display: none;">
|
||||
<div class="rubric-label">
|
||||
<label class="rubric-elements-info">
|
||||
<span class="wrapper-score-selection"> </span>
|
||||
<span class="wrappable"> 2 points :
|
||||
Presents a unifying theme or main idea, but may include minor tangents. Stays somewhat focused on topic and task.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="rubric-list-item rubric-info-item" style="display: none;">
|
||||
<div class="rubric-label">
|
||||
<label class="rubric-elements-info">
|
||||
<span class="wrapper-score-selection"> </span>
|
||||
<span class="wrappable"> 3 points :
|
||||
Presents a unifying theme or main idea without going off on tangents. Stays completely focused on topic and task.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<span class="rubric-category">
|
||||
Content
|
||||
</span> <br>
|
||||
<ul class="rubric-list">
|
||||
|
||||
<li class="rubric-list-item">
|
||||
<div class="rubric-label">
|
||||
<label class="choicegroup_incorrect wrapper-score-selection"></label>
|
||||
<span class="wrappable"> 0 points :
|
||||
Includes little information with few or no details or unrelated details. Unsuccessful in attempts to explore any facets of the topic.
|
||||
</span>
|
||||
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="rubric-list-item rubric-info-item" style="display: none;">
|
||||
<div class="rubric-label">
|
||||
<label class="rubric-elements-info">
|
||||
<span class="wrapper-score-selection"> </span>
|
||||
<span class="wrappable"> 1 points :
|
||||
Includes little information and few or no details. Explores only one or two facets of the topic.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="rubric-list-item rubric-info-item" style="display: none;">
|
||||
<div class="rubric-label">
|
||||
<label class="rubric-elements-info">
|
||||
<span class="wrapper-score-selection"> </span>
|
||||
<span class="wrappable"> 2 points :
|
||||
Includes sufficient information and supporting details. (Details may not be fully developed; ideas may be listed.) Explores some facets of the topic.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="rubric-list-item rubric-info-item" style="display: none;">
|
||||
<div class="rubric-label">
|
||||
<label class="rubric-elements-info">
|
||||
<span class="wrapper-score-selection"> </span>
|
||||
<span class="wrappable"> 3 points :
|
||||
Includes in-depth information and exceptional supporting details that are fully developed. Explores all facets of the topic.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<span class="rubric-category">
|
||||
Organization
|
||||
</span> <br>
|
||||
<ul class="rubric-list">
|
||||
|
||||
<li class="rubric-list-item">
|
||||
<div class="rubric-label">
|
||||
<label class="choicegroup_incorrect wrapper-score-selection"></label>
|
||||
<span class="wrappable"> 0 points :
|
||||
Ideas organized illogically, transitions weak, and response difficult to follow.
|
||||
</span>
|
||||
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="rubric-list-item rubric-info-item" style="display: none;">
|
||||
<div class="rubric-label">
|
||||
<label class="rubric-elements-info">
|
||||
<span class="wrapper-score-selection"> </span>
|
||||
<span class="wrappable"> 1 points :
|
||||
Attempts to logically organize ideas. Attempts to progress in an order that enhances meaning, and demonstrates use of transitions.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="rubric-list-item rubric-info-item" style="display: none;">
|
||||
<div class="rubric-label">
|
||||
<label class="rubric-elements-info">
|
||||
<span class="wrapper-score-selection"> </span>
|
||||
<span class="wrappable"> 2 points :
|
||||
Ideas organized logically. Progresses in an order that enhances meaning. Includes smooth transitions.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<span class="rubric-category">
|
||||
Style
|
||||
</span> <br>
|
||||
<ul class="rubric-list">
|
||||
|
||||
<li class="rubric-list-item">
|
||||
<div class="rubric-label">
|
||||
<label class="choicegroup_incorrect wrapper-score-selection"></label>
|
||||
<span class="wrappable"> 0 points :
|
||||
Contains limited vocabulary, with many words used incorrectly. Demonstrates problems with sentence patterns.
|
||||
</span>
|
||||
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="rubric-list-item rubric-info-item" style="display: none;">
|
||||
<div class="rubric-label">
|
||||
<label class="rubric-elements-info">
|
||||
<span class="wrapper-score-selection"> </span>
|
||||
<span class="wrappable"> 1 points :
|
||||
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).
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="rubric-list-item rubric-info-item" style="display: none;">
|
||||
<div class="rubric-label">
|
||||
<label class="rubric-elements-info">
|
||||
<span class="wrapper-score-selection"> </span>
|
||||
<span class="wrappable"> 2 points :
|
||||
Includes vocabulary to make explanations detailed and precise. Includes varied sentence patterns, including complex sentences.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<span class="rubric-category">
|
||||
Voice
|
||||
</span> <br>
|
||||
<ul class="rubric-list">
|
||||
|
||||
<li class="rubric-list-item">
|
||||
<div class="rubric-label">
|
||||
<label class="choicegroup_incorrect wrapper-score-selection"></label>
|
||||
<span class="wrappable"> 0 points :
|
||||
Demonstrates language and tone that may be inappropriate to task and reader.
|
||||
</span>
|
||||
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="rubric-list-item rubric-info-item" style="display: none;">
|
||||
<div class="rubric-label">
|
||||
<label class="rubric-elements-info">
|
||||
<span class="wrapper-score-selection"> </span>
|
||||
<span class="wrappable"> 1 points :
|
||||
Demonstrates an attempt to adjust language and tone to task and reader.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="rubric-list-item rubric-info-item" style="display: none;">
|
||||
<div class="rubric-label">
|
||||
<label class="rubric-elements-info">
|
||||
<span class="wrapper-score-selection"> </span>
|
||||
<span class="wrappable"> 2 points :
|
||||
Demonstrates effective adjustment of language and tone to task and reader.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="written-feedback">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="button" value="Next Step" class="next-step-button" name="reset" style="display: none;">
|
||||
|
||||
<section class="legend-container">
|
||||
</section>
|
||||
|
||||
<div class="result-container">
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user