Commit 8baddc06 by Steve Strassmann

Merge branch 'master' of https://github.com/MITx/mitx into feature/i18n

parents 6f103488 57427f9a
......@@ -34,16 +34,23 @@ load-plugins=
# multiple time (only on the command line, not in the configuration file where
# it should appear only once).
disable=
# Never going to use these
# C0301: Line too long
# W0141: Used builtin function 'map'
# W0142: Used * or ** magic
# W0141: Used builtin function 'map'
# Might use these when the code is in better shape
# C0302: Too many lines in module
# R0201: Method could be a function
# R0901: Too many ancestors
# R0902: Too many instance attributes
# R0903: Too few public methods (1/2)
# R0904: Too many public methods
# R0911: Too many return statements
# R0912: Too many branches
# R0913: Too many arguments
C0301,W0141,W0142,R0201,R0901,R0902,R0903,R0904,R0913
# R0914: Too many local variables
C0301,C0302,W0141,W0142,R0201,R0901,R0902,R0903,R0904,R0911,R0912,R0913,R0914
[REPORTS]
......@@ -92,7 +99,18 @@ zope=no
# List of members which are set dynamically and missed by pylint inference
# system, and so shouldn't trigger E0201 when accessed. Python regular
# expressions are accepted.
generated-members=REQUEST,acl_users,aq_parent,objects,DoesNotExist,can_read,can_write,get_url,size
generated-members=
REQUEST,
acl_users,
aq_parent,
objects,
DoesNotExist,
can_read,
can_write,
get_url,
size,
content,
status_code
[BASIC]
......
......@@ -22,5 +22,4 @@ libreadline6
libreadline6-dev
mongodb
nodejs
npm
coffeescript
import logging
import sys
from django.contrib.auth.models import User, Group
from django.core.exceptions import PermissionDenied
......@@ -131,7 +128,7 @@ def remove_user_from_course_group(caller, user, location, role):
raise PermissionDenied
# see if the user is actually in that role, if not then we don't have to do anything
if is_user_in_course_group_role(user, location, role) == True:
if is_user_in_course_group_role(user, location, role):
groupname = get_course_groupname_for_role(location, role)
group = Group.objects.get(name=groupname)
......
......@@ -97,8 +97,7 @@ def update_course_updates(location, update, passed_id=None):
if (len(new_html_parsed) == 1):
content = new_html_parsed[0].tail
else:
content = "\n".join([html.tostring(ele)
for ele in new_html_parsed[1:]])
content = "\n".join([html.tostring(ele) for ele in new_html_parsed[1:]])
return {"id": passed_id,
"date": update['date'],
......
......@@ -11,6 +11,7 @@ Feature: Advanced (manual) course policy
Given I am on the Advanced Course Settings page in Studio
Then the settings are alphabetized
@skip-phantom
Scenario: Test cancel editing key value
Given I am on the Advanced Course Settings page in Studio
When I edit the value of a policy key
......@@ -19,6 +20,7 @@ Feature: Advanced (manual) course policy
And I reload the page
Then the policy key value is unchanged
@skip-phantom
Scenario: Test editing key value
Given I am on the Advanced Course Settings page in Studio
When I edit the value of a policy key and save
......@@ -26,6 +28,7 @@ Feature: Advanced (manual) course policy
And I reload the page
Then the policy key value is changed
@skip-phantom
Scenario: Test how multi-line input appears
Given I am on the Advanced Course Settings page in Studio
When I create a JSON object as a value
......@@ -33,6 +36,7 @@ Feature: Advanced (manual) course policy
And I reload the page
Then it is displayed as formatted
@skip-phantom
Scenario: Test automatic quoting of non-JSON values
Given I am on the Advanced Course Settings page in Studio
When I create a non-JSON value not in quotes
......
......@@ -3,10 +3,7 @@
from lettuce import world, step
from common import *
import time
from terrain.steps import reload_the_page
from nose.tools import assert_true, assert_false, assert_equal
from nose.tools import assert_false, assert_equal
"""
http://selenium.googlecode.com/svn/trunk/docs/api/py/webdriver/selenium.webdriver.common.keys.html
......@@ -18,8 +15,8 @@ VALUE_CSS = 'textarea.json'
DISPLAY_NAME_KEY = "display_name"
DISPLAY_NAME_VALUE = '"Robot Super Course"'
############### ACTIONS ####################
############### ACTIONS ####################
@step('I select the Advanced Settings$')
def i_select_advanced_settings(step):
expand_icon_css = 'li.nav-course-settings i.icon-expand'
......@@ -38,7 +35,7 @@ def i_am_on_advanced_course_settings(step):
@step(u'I press the "([^"]*)" notification button$')
def press_the_notification_button(step, name):
css = 'a.%s-button' % name.lower()
world.css_click_at(css)
world.css_click(css)
@step(u'I edit the value of a policy key$')
......@@ -52,7 +49,7 @@ def edit_the_value_of_a_policy_key(step):
@step(u'I edit the value of a policy key and save$')
def edit_the_value_of_a_policy_key(step):
def edit_the_value_of_a_policy_key_and_save(step):
change_display_name_value(step, '"foo"')
......@@ -90,7 +87,7 @@ def it_is_formatted(step):
@step('it is displayed as a string')
def it_is_formatted(step):
def it_is_displayed_as_string(step):
assert_policy_entries([DISPLAY_NAME_KEY], ['"quote me"'])
......
......@@ -10,6 +10,8 @@ Feature: Course checklists
Then I can check and uncheck tasks in a checklist
And They are correctly selected after I reload the page
@skip-phantom
@skip-firefox
Scenario: A task can link to a location within Studio
Given I have opened Checklists
When I select a link to the course outline
......@@ -17,8 +19,9 @@ Feature: Course checklists
And I press the browser back button
Then I am brought back to the course outline in the correct state
@skip-phantom
@skip-firefox
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
......@@ -6,6 +6,7 @@ from nose.tools import assert_true, assert_equal
from terrain.steps import reload_the_page
from selenium.common.exceptions import StaleElementReferenceException
############### ACTIONS ####################
@step('I select Checklists from the Tools menu$')
def i_select_checklists(step):
......@@ -88,8 +89,6 @@ def i_am_brought_to_help_page_in_new_window(step):
assert_equal('http://help.edge.edx.org/', world.browser.url)
############### HELPER METHODS ####################
def verifyChecklist2Status(completed, total, percentage):
def verify_count(driver):
......@@ -106,9 +105,11 @@ def verifyChecklist2Status(completed, total, percentage):
def toggleTask(checklist, task):
world.css_click('#course-checklist' + str(checklist) +'-task' + str(task))
world.css_click('#course-checklist' + str(checklist) + '-task' + str(task))
# TODO: figure out a way to do this in phantom and firefox
# For now we will mark the scenerios that use this method as skipped
def clickActionLink(checklist, task, actionText):
# toggle checklist item to make sure that the link button is showing
toggleTask(checklist, task)
......@@ -120,4 +121,3 @@ def clickActionLink(checklist, task, actionText):
world.wait_for(verify_action_link_text)
action_link.click()
Feature: Course Settings
As a course author, I want to be able to configure my course settings.
@skip-phantom
Scenario: User can set course dates
Given I have opened a new course in Studio
When I select Schedule and Details
And I set course dates
Then I see the set dates on refresh
@skip-phantom
Scenario: User can clear previously set course dates (except start date)
Given I have set course dates
And I clear all the dates except start
Then I see cleared dates on refresh
@skip-phantom
Scenario: User cannot clear the course start date
Given I have set course dates
And I clear the course start date
......
......@@ -3,6 +3,7 @@ Feature: Create Section
As a course author
I want to create and edit sections
@skip-phantom
Scenario: Add a new section to a course
Given I have opened a new course in Studio
When I click the New Section link
......
......@@ -14,6 +14,7 @@ Feature: Overview Toggle Section
When I navigate to the course overview page
Then I do not see the "Collapse All Sections" link
@skip-phantom
Scenario: Collapse link appears after creating first section of a course
Given I have a course with no sections
When I navigate to the course overview page
......
......@@ -3,12 +3,14 @@ Feature: Create Subsection
As a course author
I want to create and edit subsections
@skip-phantom
Scenario: Add a new subsection to a section
Given I have opened a new course section in Studio
When I click the New Subsection link
And I enter the subsection name and click save
Then I see my subsection on the Courseware page
@skip-phantom
Scenario: Add a new subsection (with a name containing a quote) to a section (bug #216)
Given I have opened a new course section in Studio
When I click the New Subsection link
......@@ -25,6 +27,7 @@ Feature: Create Subsection
And I reload the page
Then I see it marked as Homework
@skip-phantom
Scenario: Set a due date in a different year (bug #256)
Given I have opened a new subsection in Studio
And I have set a release date and due date in different years
......@@ -40,5 +43,3 @@ Feature: Create Subsection
When I press the "subsection" delete icon
And I confirm the alert
Then the subsection does not exist
......@@ -5,7 +5,6 @@ from django.core.management.base import BaseCommand, CommandError
from xmodule.modulestore.store_utilities import clone_course
from xmodule.modulestore.django import modulestore
from xmodule.contentstore.django import contentstore
from xmodule.modulestore import Location
from xmodule.course_module import CourseDescriptor
from auth.authz import _copy_course_group
......@@ -16,8 +15,7 @@ from auth.authz import _copy_course_group
class Command(BaseCommand):
help = \
'''Clone a MongoDB backed course to another location'''
help = 'Clone a MongoDB backed course to another location'
def handle(self, *args, **options):
if len(args) != 2:
......
......@@ -5,7 +5,6 @@ from django.core.management.base import BaseCommand, CommandError
from xmodule.modulestore.store_utilities import delete_course
from xmodule.modulestore.django import modulestore
from xmodule.contentstore.django import contentstore
from xmodule.modulestore import Location
from xmodule.course_module import CourseDescriptor
from .prompt import query_yes_no
......@@ -38,7 +37,7 @@ class Command(BaseCommand):
if query_yes_no("Deleting course {0}. Confirm?".format(loc_str), default="no"):
if query_yes_no("Are you sure. This action cannot be undone!", default="no"):
loc = CourseDescriptor.id_to_location(loc_str)
if delete_course(ms, cs, loc, commit) == True:
if delete_course(ms, cs, loc, commit):
print 'removing User permissions from course....'
# in the django layer, we need to remove all the user permissions groups associated with this course
if commit:
......
......@@ -7,7 +7,6 @@ from django.core.management.base import BaseCommand, CommandError
from xmodule.modulestore.xml_exporter import export_to_xml
from xmodule.modulestore.django import modulestore
from xmodule.contentstore.django import contentstore
from xmodule.modulestore import Location
from xmodule.course_module import CourseDescriptor
......@@ -15,8 +14,7 @@ unnamed_modules = 0
class Command(BaseCommand):
help = \
'''Import the specified data directory into the default ModuleStore'''
help = 'Import the specified data directory into the default ModuleStore'
def handle(self, *args, **options):
if len(args) != 2:
......
......@@ -12,8 +12,7 @@ unnamed_modules = 0
class Command(BaseCommand):
help = \
'''Import the specified data directory into the default ModuleStore'''
help = 'Import the specified data directory into the default ModuleStore'
def handle(self, *args, **options):
if len(args) == 0:
......
......@@ -11,8 +11,8 @@ def query_yes_no(question, default="yes"):
The "answer" return value is one of "yes" or "no".
"""
valid = {"yes":True, "y":True, "ye":True,
"no":False, "n":False}
valid = {"yes": True, "y": True, "ye": True,
"no": False, "n": False}
if default is None:
prompt = " [y/n] "
elif default == "yes":
......@@ -30,5 +30,4 @@ def query_yes_no(question, default="yes"):
elif choice in valid:
return valid[choice]
else:
sys.stdout.write("Please respond with 'yes' or 'no' "\
"(or 'y' or 'n').\n")
sys.stdout.write("Please respond with 'yes' or 'no' (or 'y' or 'n').\n")
from xmodule.templates import update_templates
from django.core.management.base import BaseCommand
class Command(BaseCommand):
help = \
'''Imports and updates the Studio component templates from the code pack and put in the DB'''
help = 'Imports and updates the Studio component templates from the code pack and put in the DB'
def handle(self, *args, **options):
update_templates()
from django.core.management.base import BaseCommand, CommandError
from xmodule.modulestore.xml_importer import perform_xlint
from xmodule.modulestore.django import modulestore
from xmodule.contentstore.django import contentstore
unnamed_modules = 0
......@@ -13,6 +11,7 @@ class Command(BaseCommand):
Verify the structure of courseware as to it's suitability for import
To run test: rake cms:xlint DATA_DIR=../data [COURSE_DIR=content-edx-101 (optional parameter)]
'''
def handle(self, *args, **options):
if len(args) == 0:
raise CommandError("import requires at least one argument: <data directory> [<course dir>...]")
......
from static_replace import replace_static_urls
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore import Location
from django.http import Http404
def get_module_info(store, location, parent_location=None, rewrite_static_links=False):
......
......@@ -8,12 +8,11 @@ from django.core.urlresolvers import reverse
from django.utils.timezone import UTC
from xmodule.modulestore import Location
from models.settings.course_details import (CourseDetails,
CourseSettingsEncoder)
from models.settings.course_details import (CourseDetails, CourseSettingsEncoder)
from models.settings.course_grading import CourseGradingModel
from contentstore.utils import get_modulestore
from .utils import ModuleStoreTestCase
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from models.settings.course_metadata import CourseMetadata
......@@ -21,6 +20,7 @@ from xmodule.modulestore.xml_importer import import_from_xml
from xmodule.modulestore.django import modulestore
from xmodule.fields import Date
class CourseTestCase(ModuleStoreTestCase):
def setUp(self):
"""
......@@ -47,12 +47,8 @@ class CourseTestCase(ModuleStoreTestCase):
self.client = Client()
self.client.login(username=uname, password=password)
t = 'i4x://edx/templates/course/Empty'
o = 'MITx'
n = '999'
dn = 'Robot Super Course'
self.course_location = Location('i4x', o, n, 'course', 'Robot_Super_Course')
CourseFactory.create(template=t, org=o, number=n, display_name=dn)
course = CourseFactory.create(template='i4x://edx/templates/course/Empty', org='MITx', number='999', display_name='Robot Super Course')
self.course_location = course.location
class CourseDetailsTestCase(CourseTestCase):
......@@ -86,17 +82,25 @@ class CourseDetailsTestCase(CourseTestCase):
jsondetails = CourseDetails.fetch(self.course_location)
jsondetails.syllabus = "<a href='foo'>bar</a>"
# encode - decode to convert date fields and other data which changes form
self.assertEqual(CourseDetails.update_from_json(jsondetails.__dict__).syllabus,
jsondetails.syllabus, "After set syllabus")
self.assertEqual(
CourseDetails.update_from_json(jsondetails.__dict__).syllabus,
jsondetails.syllabus, "After set syllabus"
)
jsondetails.overview = "Overview"
self.assertEqual(CourseDetails.update_from_json(jsondetails.__dict__).overview,
jsondetails.overview, "After set overview")
self.assertEqual(
CourseDetails.update_from_json(jsondetails.__dict__).overview,
jsondetails.overview, "After set overview"
)
jsondetails.intro_video = "intro_video"
self.assertEqual(CourseDetails.update_from_json(jsondetails.__dict__).intro_video,
jsondetails.intro_video, "After set intro_video")
self.assertEqual(
CourseDetails.update_from_json(jsondetails.__dict__).intro_video,
jsondetails.intro_video, "After set intro_video"
)
jsondetails.effort = "effort"
self.assertEqual(CourseDetails.update_from_json(jsondetails.__dict__).effort,
jsondetails.effort, "After set effort")
self.assertEqual(
CourseDetails.update_from_json(jsondetails.__dict__).effort,
jsondetails.effort, "After set effort"
)
class CourseDetailsViewTest(CourseTestCase):
......@@ -150,9 +154,7 @@ class CourseDetailsViewTest(CourseTestCase):
@staticmethod
def struct_to_datetime(struct_time):
return datetime.datetime(struct_time.tm_year, struct_time.tm_mon,
struct_time.tm_mday, struct_time.tm_hour,
struct_time.tm_min, struct_time.tm_sec, tzinfo=UTC())
return datetime.datetime(*struct_time[:6], tzinfo=UTC())
def compare_date_fields(self, details, encoded, context, field):
if details[field] is not None:
......@@ -249,6 +251,7 @@ class CourseGradingTest(CourseTestCase):
altered_grader = CourseGradingModel.update_grader_from_json(test_grader.course_location, test_grader.graders[1])
self.assertDictEqual(test_grader.graders[1], altered_grader, "drop_count[1] + 2")
class CourseMetadataEditingTest(CourseTestCase):
def setUp(self):
CourseTestCase.setUp(self)
......@@ -256,7 +259,6 @@ class CourseMetadataEditingTest(CourseTestCase):
import_from_xml(modulestore(), 'common/test/data/', ['full'])
self.fullcourse_location = Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None])
def test_fetch_initial_fields(self):
test_model = CourseMetadata.fetch(self.course_location)
self.assertIn('display_name', test_model, 'Missing editable metadata field')
......@@ -271,18 +273,20 @@ class CourseMetadataEditingTest(CourseTestCase):
self.assertIn('xqa_key', test_model, 'xqa_key field ')
def test_update_from_json(self):
test_model = CourseMetadata.update_from_json(self.course_location,
{ "advertised_start" : "start A",
"testcenter_info" : { "c" : "test" },
"days_early_for_beta" : 2})
test_model = CourseMetadata.update_from_json(self.course_location, {
"advertised_start": "start A",
"testcenter_info": {"c": "test"},
"days_early_for_beta": 2
})
self.update_check(test_model)
# try fresh fetch to ensure persistence
test_model = CourseMetadata.fetch(self.course_location)
self.update_check(test_model)
# now change some of the existing metadata
test_model = CourseMetadata.update_from_json(self.course_location,
{ "advertised_start" : "start B",
"display_name" : "jolly roger"})
test_model = CourseMetadata.update_from_json(self.course_location, {
"advertised_start": "start B",
"display_name": "jolly roger"}
)
self.assertIn('display_name', test_model, 'Missing editable metadata field')
self.assertEqual(test_model['display_name'], 'jolly roger', "not expected value")
self.assertIn('advertised_start', test_model, 'Missing revised advertised_start metadata field')
......@@ -294,13 +298,12 @@ class CourseMetadataEditingTest(CourseTestCase):
self.assertIn('advertised_start', test_model, 'Missing new advertised_start metadata field')
self.assertEqual(test_model['advertised_start'], 'start A', "advertised_start not expected value")
self.assertIn('testcenter_info', test_model, 'Missing testcenter_info metadata field')
self.assertDictEqual(test_model['testcenter_info'], { "c" : "test" }, "testcenter_info not expected value")
self.assertDictEqual(test_model['testcenter_info'], {"c": "test"}, "testcenter_info not expected value")
self.assertIn('days_early_for_beta', test_model, 'Missing days_early_for_beta metadata field')
self.assertEqual(test_model['days_early_for_beta'], 2, "days_early_for_beta not expected value")
def test_delete_key(self):
test_model = CourseMetadata.delete_key(self.fullcourse_location, { 'deleteKeys' : ['doesnt_exist', 'showanswer', 'xqa_key']})
test_model = CourseMetadata.delete_key(self.fullcourse_location, {'deleteKeys': ['doesnt_exist', 'showanswer', 'xqa_key']})
# ensure no harm
self.assertNotIn('graceperiod', test_model, 'blacklisted field leaked in')
self.assertIn('display_name', test_model, 'full missing editable metadata field')
......
......@@ -3,7 +3,7 @@ from contentstore import utils
import mock
from django.test import TestCase
from xmodule.modulestore.tests.factories import CourseFactory
from .utils import ModuleStoreTestCase
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
class LMSLinksTestCase(TestCase):
......@@ -30,7 +30,7 @@ class LMSLinksTestCase(TestCase):
class UrlReverseTestCase(ModuleStoreTestCase):
""" Tests for get_url_reverse """
def test_CoursePageNames(self):
def test_course_page_names(self):
""" Test the defined course pages. """
course = CourseFactory.create(org='mitX', number='666', display_name='URL Reverse Course')
......
import json
import shutil
from django.test.client import Client
from django.conf import settings
from django.core.urlresolvers import reverse
from path import path
import json
from fs.osfs import OSFS
import copy
from contentstore.utils import get_modulestore
from xmodule.modulestore import Location
from xmodule.modulestore.store_utilities import clone_course
from xmodule.modulestore.store_utilities import delete_course
from xmodule.modulestore.django import modulestore, _MODULESTORES
from xmodule.contentstore.django import contentstore
from xmodule.templates import update_templates
from xmodule.modulestore.xml_exporter import export_to_xml
from xmodule.modulestore.xml_importer import import_from_xml
from xmodule.capa_module import CapaDescriptor
from xmodule.course_module import CourseDescriptor
from xmodule.seq_module import SequenceDescriptor
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from .utils import ModuleStoreTestCase, parse_json, user, registration
from .utils import parse_json, user, registration
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
class ContentStoreTestCase(ModuleStoreTestCase):
......@@ -84,6 +62,7 @@ class ContentStoreTestCase(ModuleStoreTestCase):
# Now make sure that the user is now actually activated
self.assertTrue(user(email).is_active)
class AuthTestCase(ContentStoreTestCase):
"""Check that various permissions-related things work"""
......
......@@ -2,112 +2,11 @@
Utilities for contentstore tests
'''
#pylint: disable=W0603
import json
import copy
from uuid import uuid4
from django.test import TestCase
from django.conf import settings
from student.models import Registration
from django.contrib.auth.models import User
import xmodule.modulestore.django
from xmodule.templates import update_templates
class ModuleStoreTestCase(TestCase):
""" Subclass for any test case that uses the mongodb
module store. This populates a uniquely named modulestore
collection with templates before running the TestCase
and drops it they are finished. """
@staticmethod
def flush_mongo_except_templates():
'''
Delete everything in the module store except templates
'''
modulestore = xmodule.modulestore.django.modulestore()
# This query means: every item in the collection
# that is not a template
query = {"_id.course": {"$ne": "templates"}}
# Remove everything except templates
modulestore.collection.remove(query)
@staticmethod
def load_templates_if_necessary():
'''
Load templates into the modulestore only if they do not already exist.
We need the templates, because they are copied to create
XModules such as sections and problems
'''
modulestore = xmodule.modulestore.django.modulestore()
# Count the number of templates
query = {"_id.course": "templates"}
num_templates = modulestore.collection.find(query).count()
if num_templates < 1:
update_templates()
@classmethod
def setUpClass(cls):
'''
Flush the mongo store and set up templates
'''
# Use a uuid to differentiate
# the mongo collections on jenkins.
cls.orig_modulestore = copy.deepcopy(settings.MODULESTORE)
test_modulestore = cls.orig_modulestore
test_modulestore['default']['OPTIONS']['collection'] = 'modulestore_%s' % uuid4().hex
test_modulestore['direct']['OPTIONS']['collection'] = 'modulestore_%s' % uuid4().hex
xmodule.modulestore.django._MODULESTORES = {}
settings.MODULESTORE = test_modulestore
TestCase.setUpClass()
@classmethod
def tearDownClass(cls):
'''
Revert to the old modulestore settings
'''
# Clean up by dropping the collection
modulestore = xmodule.modulestore.django.modulestore()
modulestore.collection.drop()
# Restore the original modulestore settings
settings.MODULESTORE = cls.orig_modulestore
def _pre_setup(self):
'''
Remove everything but the templates before each test
'''
# Flush anything that is not a template
ModuleStoreTestCase.flush_mongo_except_templates()
# Check that we have templates loaded; if not, load them
ModuleStoreTestCase.load_templates_if_necessary()
# Call superclass implementation
super(ModuleStoreTestCase, self)._pre_setup()
def _post_teardown(self):
'''
Flush everything we created except the templates
'''
# Flush anything that is not a template
ModuleStoreTestCase.flush_mongo_except_templates()
# Call superclass implementation
super(ModuleStoreTestCase, self)._post_teardown()
def parse_json(response):
"""Parse response, which is assumed to be json"""
......
import logging
from django.conf import settings
from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
......@@ -9,7 +8,8 @@ import copy
DIRECT_ONLY_CATEGORIES = ['course', 'chapter', 'sequential', 'about', 'static_tab', 'course_info']
#In order to instantiate an open ended tab automatically, need to have this data
OPEN_ENDED_PANEL = {"name" : "Open Ended Panel", "type" : "open_ended"}
OPEN_ENDED_PANEL = {"name": "Open Ended Panel", "type": "open_ended"}
def get_modulestore(location):
"""
......@@ -86,8 +86,7 @@ def get_lms_link_for_item(location, preview=False, course_id=None):
if settings.LMS_BASE is not None:
if preview:
lms_base = settings.MITX_FEATURES.get('PREVIEW_LMS_BASE',
'preview.' + settings.LMS_BASE)
lms_base = settings.MITX_FEATURES.get('PREVIEW_LMS_BASE', 'preview.' + settings.LMS_BASE)
else:
lms_base = settings.LMS_BASE
......@@ -192,6 +191,7 @@ class CoursePageNames:
CourseOutline = "course_index"
Checklists = "checklists"
def add_open_ended_panel_tab(course):
"""
Used to add the open ended panel tab to a course if it does not exist.
......@@ -208,6 +208,7 @@ def add_open_ended_panel_tab(course):
changed = True
return changed, course_tabs
def remove_open_ended_panel_tab(course):
"""
Used to remove the open ended panel tab from a course if it exists.
......@@ -220,6 +221,6 @@ def remove_open_ended_panel_tab(course):
#Check to see if open ended panel is defined in the course
if OPEN_ENDED_PANEL in course_tabs:
#Add panel to the tabs if it is not defined
course_tabs = [ct for ct in course_tabs if ct!=OPEN_ENDED_PANEL]
course_tabs = [ct for ct in course_tabs if ct != OPEN_ENDED_PANEL]
changed = True
return changed, course_tabs
......@@ -14,9 +14,6 @@ from tempfile import mkdtemp
from django.core.servers.basehttp import FileWrapper
from django.core.files.temp import NamedTemporaryFile
# to install PIL on MacOSX: 'easy_install http://dist.repoze.org/PIL-1.1.6.tar.gz'
from PIL import Image
from django.http import HttpResponse, Http404, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseServerError
from django.http import HttpResponseNotFound
from django.contrib.auth.decorators import login_required
......@@ -120,6 +117,12 @@ def howitworks(request):
else:
return render_to_response('howitworks.html', {})
# static/proof-of-concept views
def ux_alerts(request):
return render_to_response('ux-alerts.html', {})
# ==== Views for any logged-in user ==================================
......@@ -238,8 +241,7 @@ def edit_subsection(request, location):
(field.name, field.read_from(item))
for field
in item.fields
if field.name not in ['display_name', 'start', 'due', 'format'] and
field.scope == Scope.settings
if field.name not in ['display_name', 'start', 'due', 'format'] and field.scope == Scope.settings
)
can_view_live = False
......@@ -617,7 +619,6 @@ def delete_item(request):
store = get_modulestore(item_loc)
# @TODO: this probably leaves draft items dangling. My preferance would be for the semantic to be
# if item.location.revision=None, then delete both draft and published version
# if caller wants to only delete the draft than the caller should put item.location.revision='draft'
......@@ -659,7 +660,7 @@ def save_item(request):
if not has_access(request.user, item_location):
raise PermissionDenied()
store = get_modulestore(Location(item_location));
store = get_modulestore(Location(item_location))
if request.POST.get('data') is not None:
data = request.POST['data']
......@@ -794,7 +795,7 @@ def upload_asset(request, org, course, coursename):
# Does the course actually exist?!? Get anything from it to prove its existance
try:
item = modulestore().get_item(location)
modulestore().get_item(location)
except:
# no return it as a Bad Request response
logging.error('Could not find course' + location)
......@@ -839,13 +840,12 @@ def upload_asset(request, org, course, coursename):
return response
'''
This view will return all CMS users who are editors for the specified course
'''
@login_required
@ensure_csrf_cookie
def manage_users(request, location):
'''
This view will return all CMS users who are editors for the specified course
'''
# check that logged in user has permissions to this item
if not has_access(request.user, location, role=INSTRUCTOR_ROLE_NAME) and not has_access(request.user, location, role=STAFF_ROLE_NAME):
raise PermissionDenied()
......@@ -872,14 +872,14 @@ def create_json_response(errmsg=None):
return resp
'''
This POST-back view will add a user - specified by email - to the list of editors for
the specified course
'''
@expect_json
@login_required
@ensure_csrf_cookie
def add_user(request, location):
'''
This POST-back view will add a user - specified by email - to the list of editors for
the specified course
'''
email = request.POST["email"]
if email == '':
......@@ -905,14 +905,15 @@ def add_user(request, location):
return create_json_response()
'''
This POST-back view will remove a user - specified by email - from the list of editors for
the specified course
'''
@expect_json
@login_required
@ensure_csrf_cookie
def remove_user(request, location):
'''
This POST-back view will remove a user - specified by email - from the list of editors for
the specified course
'''
email = request.POST["email"]
# check that logged in user has admin permissions on this course
......@@ -993,7 +994,6 @@ def reorder_static_tabs(request):
else:
reordered_tabs.append(tab)
# OK, re-assemble the static tabs in the new order
course.tabs = reordered_tabs
modulestore('direct').update_metadata(course.location, own_metadata(course))
......@@ -1005,7 +1005,6 @@ def reorder_static_tabs(request):
def edit_tabs(request, org, course, coursename):
location = ['i4x', org, course, 'course', coursename]
course_item = modulestore().get_item(location)
static_tabs_loc = Location('i4x', org, course, 'static_tab', None)
# check that logged in user has permissions to this item
if not has_access(request.user, location):
......@@ -1178,7 +1177,7 @@ def course_config_graders_page(request, org, course, name):
return render_to_response('settings_graders.html', {
'context_course': course_module,
'course_location' : location,
'course_location': location,
'course_details': json.dumps(course_details, cls=CourseSettingsEncoder)
})
......@@ -1197,8 +1196,8 @@ def course_config_advanced_page(request, org, course, name):
return render_to_response('settings_advanced.html', {
'context_course': course_module,
'course_location' : location,
'advanced_dict' : json.dumps(CourseMetadata.fetch(location)),
'course_location': location,
'advanced_dict': json.dumps(CourseMetadata.fetch(location)),
})
......@@ -1219,7 +1218,8 @@ def course_settings_updates(request, org, course, name, section):
manager = CourseDetails
elif section == 'grading':
manager = CourseGradingModel
else: return
else:
return
if request.method == 'GET':
# Cannot just do a get w/o knowing the course name :-(
......@@ -1314,6 +1314,7 @@ def course_advanced_updates(request, org, course, name):
response_json = json.dumps(CourseMetadata.update_from_json(location, request_body, filter_tabs=filter_tabs))
return HttpResponse(response_json, mimetype="application/json")
@ensure_csrf_cookie
@login_required
def get_checklists(request, org, course, name):
......@@ -1427,7 +1428,6 @@ def asset_index(request, org, course, name):
# sort in reverse upload date order
assets = sorted(assets, key=lambda asset: asset['uploadDate'], reverse=True)
thumbnails = contentstore().get_all_content_thumbnails_for_course(course_reference)
asset_display = []
for asset in assets:
id = asset['_id']
......@@ -1580,7 +1580,10 @@ def import_course(request, org, course, name):
shutil.move(r / fname, course_dir)
module_store, course_items = import_from_xml(modulestore('direct'), settings.GITHUB_REPO_ROOT,
[course_subdir], load_error_modules=False, static_content_store=contentstore(), target_location_namespace=Location(location))
[course_subdir], load_error_modules=False,
static_content_store=contentstore(),
target_location_namespace=Location(location),
draft_store=modulestore())
# we can blow this away when we're done importing.
shutil.rmtree(course_dir)
......@@ -1614,8 +1617,8 @@ def generate_export_course(request, org, course, name):
logging.debug('root = {0}'.format(root_dir))
export_to_xml(modulestore('direct'), contentstore(), loc, root_dir, name)
# filename = root_dir / name + '.tar.gz'
export_to_xml(modulestore('direct'), contentstore(), loc, root_dir, name, modulestore())
#filename = root_dir / name + '.tar.gz'
logging.debug('tar file being generated at {0}'.format(export_file.name))
tf = tarfile.open(name=export_file.name, mode='w:gz')
......
......@@ -174,7 +174,6 @@ class CourseDetails(object):
return result
# TODO move to a more general util? Is there a better way to do the isinstance model check?
class CourseSettingsEncoder(json.JSONEncoder):
def default(self, obj):
......
......@@ -45,8 +45,7 @@ class CourseGradingModel(object):
# return empty model
else:
return {
"id": index,
return {"id": index,
"type": "",
"min_count": 0,
"drop_count": 0,
......@@ -95,7 +94,6 @@ class CourseGradingModel(object):
return CourseGradingModel.fetch(course_location)
@staticmethod
def update_grader_from_json(course_location, grader):
"""
......@@ -137,7 +135,6 @@ class CourseGradingModel(object):
return cutoffs
@staticmethod
def update_grace_period_from_json(course_location, graceperiodjson):
"""
......@@ -210,8 +207,7 @@ class CourseGradingModel(object):
location = Location(location)
descriptor = get_modulestore(location).get_item(location)
return {
"graderType": descriptor.lms.format if descriptor.lms.format is not None else 'Not Graded',
return {"graderType": descriptor.lms.format if descriptor.lms.format is not None else 'Not Graded',
"location": location,
"id": 99 # just an arbitrary value to
}
......@@ -231,7 +227,6 @@ class CourseGradingModel(object):
get_modulestore(location).update_metadata(location, descriptor._model_data._kvs._metadata)
@staticmethod
def convert_set_grace_period(descriptor):
# 5 hours 59 minutes 59 seconds => converted to iso format
......@@ -262,8 +257,7 @@ class CourseGradingModel(object):
@staticmethod
def parse_grader(json_grader):
# manual to clear out kruft
result = {
"type": json_grader["type"],
result = {"type": json_grader["type"],
"min_count": int(json_grader.get('min_count', 0)),
"drop_count": int(json_grader.get('drop_count', 0)),
"short_label": json_grader.get('short_label', None),
......
......@@ -6,6 +6,7 @@ from xblock.core import Scope
from xmodule.course_module import CourseDescriptor
import copy
class CourseMetadata(object):
'''
For CRUD operations on metadata fields which do not have specific editors
......@@ -13,8 +14,13 @@ class CourseMetadata(object):
The objects have no predefined attrs but instead are obj encodings of the
editable metadata.
'''
FILTERED_LIST = XModuleDescriptor.system_metadata_fields + ['start', 'end',
'enrollment_start', 'enrollment_end', 'tabs', 'graceperiod', 'checklists']
FILTERED_LIST = XModuleDescriptor.system_metadata_fields + ['start',
'end',
'enrollment_start',
'enrollment_end',
'tabs',
'graceperiod',
'checklists']
@classmethod
def fetch(cls, course_location):
......
......@@ -36,3 +36,4 @@ DATABASES = {
INSTALLED_APPS += ('lettuce.django',)
LETTUCE_APPS = ('contentstore',)
LETTUCE_SERVER_PORT = 8001
LETTUCE_BROWSER = 'chrome'
......@@ -13,7 +13,6 @@ from path import path
# Nose Test Runner
INSTALLED_APPS += ('django_nose',)
NOSE_ARGS = ['--with-xunit']
TEST_RUNNER = 'django_nose.NoseTestSuiteRunner'
TEST_ROOT = path('test_root')
......@@ -41,7 +40,7 @@ STATICFILES_DIRS += [
if os.path.isdir(COMMON_TEST_DATA_ROOT / course_dir)
]
modulestore_options = {
MODULESTORE_OPTIONS = {
'default_class': 'xmodule.raw_module.RawDescriptor',
'host': 'localhost',
'db': 'test_xmodule',
......@@ -53,15 +52,15 @@ modulestore_options = {
MODULESTORE = {
'default': {
'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore',
'OPTIONS': modulestore_options
'OPTIONS': MODULESTORE_OPTIONS
},
'direct': {
'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore',
'OPTIONS': modulestore_options
'OPTIONS': MODULESTORE_OPTIONS
},
'draft': {
'ENGINE': 'xmodule.modulestore.mongo.DraftMongoModuleStore',
'OPTIONS': modulestore_options
'OPTIONS': MODULESTORE_OPTIONS
}
}
......@@ -76,7 +75,7 @@ CONTENTSTORE = {
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': ENV_ROOT / "db" / "cms.db",
'NAME': TEST_ROOT / "db" / "cms.db",
},
}
......@@ -121,3 +120,7 @@ PASSWORD_HASHERS = (
# dummy segment-io key
SEGMENT_IO_KEY = '***REMOVED***'
# disable NPS survey in test mode
MITX_FEATURES['STUDIO_NPS_SURVEY'] = False
from dogapi import dog_http_api, dog_stats_api
from django.conf import settings
from xmodule.modulestore.django import modulestore
from django.dispatch import Signal
from request_cache.middleware import RequestCache
from django.core.cache import get_cache, InvalidCacheBackendError
from django.core.cache import get_cache
cache = get_cache('mongo_metadata_inheritance')
for store_name in settings.MODULESTORE:
......@@ -11,6 +12,8 @@ for store_name in settings.MODULESTORE:
store.metadata_inheritance_cache_subsystem = cache
store.request_cache = RequestCache.get_request_cache()
modulestore_update_signal = Signal(providing_args=['modulestore', 'course_id', 'location'])
store.modulestore_update_signal = modulestore_update_signal
if hasattr(settings, 'DATADOG_API'):
dog_http_api.api_key = settings.DATADOG_API
dog_stats_api.start(api_key=settings.DATADOG_API, statsd=True)
if (!window.CmsUtils) window.CmsUtils = {};
var $body;
var $modal;
var $modalCover;
......@@ -48,6 +50,10 @@ $(document).ready(function () {
(e).preventDefault();
});
// alerts/notifications - manual close
$('.action-alert-close, .alert.has-actions .nav-actions a').bind('click', hideAlert);
$('.action-notification-close').bind('click', hideNotification);
// nav - dropdown related
$body.click(function (e) {
$('.nav-dropdown .nav-item .wrapper-nav-sub').removeClass('is-shown');
......@@ -87,7 +93,7 @@ $(document).ready(function () {
$('a[rel*="view"][href^="#"]').bind('click', smoothScrollLink);
// tender feedback window scrolling
$('a.show-tender').bind('click', smoothScrollTop);
$('a.show-tender').bind('click', window.CmsUtils.smoothScrollTop);
// toggling footer additional support
$('.cta-show-sock').bind('click', toggleSock);
......@@ -168,7 +174,10 @@ function smoothScrollLink(e) {
});
}
function smoothScrollTop(e) {
// On AWS instances, this base.js gets wrapped in a separate scope as part of Django static
// pipelining (note, this doesn't happen on local runtimes). So if we set it on window,
// when we can access it from other scopes (namely Course Advanced Settings).
window.CmsUtils.smoothScrollTop = function (e) {
(e).preventDefault();
$.smoothScroll({
......@@ -216,7 +225,6 @@ function toggleSections(e) {
function editSectionPublishDate(e) {
e.preventDefault();
$modal = $('.edit-subsection-publish-settings').show();
$modal = $('.edit-subsection-publish-settings').show();
$modal.attr('data-id', $(this).attr('data-id'));
$modal.find('.start-date').val($(this).attr('data-date'));
$modal.find('.start-time').val($(this).attr('data-time'));
......@@ -538,6 +546,17 @@ function removeDateSetter(e) {
$block.find('.time').val('');
}
function hideNotification(e) {
(e).preventDefault();
$(this).closest('.wrapper-notification').removeClass('is-shown').addClass('is-hiding').attr('aria-hidden','true');
}
function hideAlert(e) {
(e).preventDefault();
$(this).closest('.wrapper-alert').removeClass('is-shown');
}
function showToastMessage(message, $button, lifespan) {
var $toast = $('<div class="toast-notification"></div>');
var $closeBtn = $('<a href="#" class="close-button">×</a>');
......
......@@ -101,13 +101,13 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({
});
},
showMessage: function (type) {
this.$el.find(".message-status").removeClass("is-shown");
$(".wrapper-alert").removeClass("is-shown");
if (type) {
if (type === this.error_saving) {
this.$el.find(".message-status.error").addClass("is-shown");
$(".wrapper-alert-error").addClass("is-shown").attr('aria-hidden','false');
}
else if (type === this.successful_changes) {
this.$el.find(".message-status.confirm").addClass("is-shown");
$(".wrapper-alert-confirmation").addClass("is-shown").attr('aria-hidden','false');
this.hideSaveCancelButtons();
}
}
......@@ -117,17 +117,20 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({
}
},
showSaveCancelButtons: function(event) {
if (!this.buttonsVisible) {
if (!this.notificationBarShowing) {
this.$el.find(".message-status").removeClass("is-shown");
$('.wrapper-notification').addClass('is-shown');
this.buttonsVisible = true;
$('.wrapper-notification').removeClass('is-hiding').addClass('is-shown').attr('aria-hidden','false');
this.notificationBarShowing = true;
}
},
hideSaveCancelButtons: function() {
$('.wrapper-notification').removeClass('is-shown');
this.buttonsVisible = false;
if (this.notificationBarShowing) {
$('.wrapper-notification').removeClass('is-shown').addClass('is-hiding').attr('aria-hidden','true');
this.notificationBarShowing = false;
}
},
saveView : function(event) {
window.CmsUtils.smoothScrollTop(event);
// TODO one last verification scan:
// call validateKey on each to ensure proper format
// check for dupes
......@@ -146,6 +149,7 @@ CMS.Views.Settings.Advanced = CMS.Views.ValidatingView.extend({
});
},
revertView : function(event) {
event.preventDefault();
var self = event.data;
self.model.deleteKeys = [];
self.model.clear({silent : true});
......
......@@ -25,7 +25,7 @@ a {
@include transition(color 0.25s ease-in-out);
&:hover {
color: #cb9c40;
color: $orange-d1;
}
}
......@@ -50,8 +50,69 @@ h1 {
// ====================
// typography - basic
.title-1, .title-2, .title-3, .title-4, .title-5, .title-6 {
font-weight: 600;
color: $gray-d3;
margin: 0;
padding: 0;
}
.title-1 {
@include font-size(32);
margin-bottom: ($baseline*1.5);
}
.title-2 {
@include font-size(24);
margin-bottom: $baseline;
}
.title-3 {
@include font-size(18);
margin-bottom: ($baseline/2);
}
.title-4 {
@include font-size(14);
margin-bottom: $baseline;
font-weight: 500
}
.title-5 {
@include font-size(14);
color: $gray-l1;
margin-bottom: $baseline;
font-weight: 500
}
.title-6 {
@include font-size(14);
color: $gray-l2;
margin-bottom: $baseline;
font-weight: 500
}
p, ul, ol, dl {
margin-bottom: ($baseline/2);
&:last-child {
margin-bottom: 0;
}
}
// ====================
// layout - basic
.wrapper-view {
}
// ====================
// layout - basic page header
.wrapper-mast {
margin: ($baseline*1.5) 0 0 0;
padding: 0 $baseline;
position: relative;
......@@ -62,7 +123,7 @@ h1 {
max-width: $fg-max-width;
min-width: $fg-min-width;
width: flex-grid(12);
margin: ($baseline*1.5) auto $baseline auto;
margin: 0 auto $baseline auto;
color: $gray-d2;
}
......@@ -284,18 +345,33 @@ h1 {
margin: 0 0 ($baseline/2) 0;
}
.title-4 {
header {
@include clearfix();
.title-2 {
width: flex-grid(5, 12);
margin: 0 flex-gutter() 0 0;
float: left;
}
.title-5 {
.tip {
@include font-size(13);
width: flex-grid(7, 12);
float: right;
margin-top: ($baseline/2);
text-align: right;
color: $gray-l2;
}
}
}
// layout - supplemental content
.content-supplementary {
> section {
margin: 0 0 $baseline 0;
}
.bit {
@include font-size(13);
margin: 0 0 $baseline 0;
......@@ -761,7 +837,7 @@ body.js {
// ====================
// works in progress
// works in progress & testing
body.hide-wip {
.wip-box {
......
......@@ -15,17 +15,17 @@
// mixins - grandfathered
@mixin button {
display: inline-block;
padding: 4px 20px 6px;
font-size: 14px;
padding: ($baseline/5) $baseline ($baseline/4);
@include font-size(14);
font-weight: 700;
@include box-shadow(0 1px 0 rgba(255, 255, 255, .3) inset, 0 0 0 rgba(0, 0, 0, 0));
@include transition(background-color .15s, box-shadow .15s);
&.disabled {
border: 1px solid $lightGrey !important;
border: 1px solid $gray-l1 !important;
border-radius: 3px !important;
background: $lightGrey !important;
color: $darkGrey !important;
background: $gray-l1 !important;
color: $gray-d1 !important;
pointer-events: none;
cursor: none;
&:hover {
......@@ -38,31 +38,110 @@
}
}
@mixin green-button {
@include button;
border: 1px solid $green-d1;
border-radius: 3px;
@include linear-gradient(top, rgba(255, 255, 255, .3), rgba(255, 255, 255, 0));
background-color: $green;
@include box-shadow(0 1px 0 rgba(255, 255, 255, .3) inset);
color: $white;
&:hover {
background-color: $green-s1;
color: $white;
}
&.disabled {
border: 1px solid $green-l3 !important;
background: $green-l3 !important;
color: $white !important;
@include box-shadow(none);
}
}
@mixin blue-button {
@include button;
border: 1px solid #437fbf;
border: 1px solid $blue-d1;
border-radius: 3px;
@include linear-gradient(top, rgba(255, 255, 255, .3), rgba(255, 255, 255, 0));
background-color: $blue;
color: #fff;
color: $white;
&:hover, &.active {
background-color: #62aaf5;
color: #fff;
background-color: $blue-s2;
color: $white;
}
&.disabled {
border: 1px solid $blue-l3 !important;
background: $blue-l3 !important;
color: $white !important;
@include box-shadow(none);
}
}
@mixin green-button {
@mixin red-button {
@include button;
border: 1px solid #0d7011;
border: 1px solid $red-d1;
border-radius: 3px;
@include linear-gradient(top, rgba(255, 255, 255, .3), rgba(255, 255, 255, 0));
background-color: $green;
color: #fff;
background-color: $red;
color: $white;
&:hover, &.active {
background-color: $red-s1;
color: $white;
}
&.disabled {
border: 1px solid $red-l3 !important;
background: $red-l3 !important;
color: $white !important;
@include box-shadow(none);
}
}
@mixin pink-button {
@include button;
border: 1px solid $pink-d1;
border-radius: 3px;
@include linear-gradient(top, rgba(255, 255, 255, .3), rgba(255, 255, 255, 0));
background-color: $pink;
color: $white;
&:hover, &.active {
background-color: $pink-s1;
color: $white;
}
&.disabled {
border: 1px solid $pink-l3 !important;
background: $pink-l3 !important;
color: $white !important;
@include box-shadow(none);
}
}
@mixin orange-button {
@include button;
border: 1px solid $orange-d1;
border-radius: 3px;
@include linear-gradient(top, rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0) 60%);
background-color: $orange;
@include box-shadow(0 1px 0 rgba(255, 255, 255, .3) inset);
color: $gray-d2;
&:hover {
background-color: #129416;
color: #fff;
background-color: $orange-s2;
color: $gray-d2;
}
&.disabled {
border: 1px solid $orange-l3 !important;
background: $orange-l2 !important;
color: $gray-l1 !important;
@include box-shadow(none);
}
}
......@@ -82,24 +161,9 @@
}
}
@mixin orange-button {
@include button;
border: 1px solid #bda046;
border-radius: 3px;
@include linear-gradient(top, rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0) 60%);
background-color: #edbd3c;
@include box-shadow(0 1px 0 rgba(255, 255, 255, .3) inset);
color: #3c3c3c;
&:hover {
background-color: #ffcd46;
color: #3c3c3c;
}
}
@mixin grey-button {
@include button;
border: 1px solid $darkGrey;
border: 1px solid $gray-d2;
border-radius: 3px;
@include linear-gradient(top, rgba(255, 255, 255, .3), rgba(255, 255, 255, 0));
background-color: #d1dae3;
......@@ -127,39 +191,17 @@
}
}
@mixin green-button {
@include button;
border: 1px solid $darkGreen;
border-radius: 3px;
@include linear-gradient(top, rgba(255, 255, 255, .3), rgba(255, 255, 255, 0));
background-color: $green;
@include box-shadow(0 1px 0 rgba(255, 255, 255, .3) inset);
color: #fff;
&:hover {
background-color: $brightGreen;
color: #fff;
}
&.disabled {
border: 1px solid $disabledGreen !important;
background: $disabledGreen !important;
color: #fff !important;
@include box-shadow(none);
}
}
@mixin dark-grey-button {
@include button;
border: 1px solid #1c1e20;
border: 1px solid $gray-d2;
border-radius: 3px;
background: -webkit-linear-gradient(top, rgba(255, 255, 255, .2), rgba(255, 255, 255, 0)) $extraDarkGrey;
background: -webkit-linear-gradient(top, rgba(255, 255, 255, .2), rgba(255, 255, 255, 0)) $gray-d1;
box-shadow: 0 1px 0 rgba(255, 255, 255, .2) inset;
color: #fff;
color: $white;
&:hover {
background-color: #595f64;
color: #fff;
background-color: $gray-d4;
color: $white;
}
}
......@@ -296,6 +338,9 @@
}
}
// ====================
// sunsetted mixins
@mixin active {
@include linear-gradient(top, rgba(255, 255, 255, .4), rgba(255, 255, 255, 0));
background-color: rgba(255, 255, 255, .3);
......
......@@ -113,7 +113,7 @@ $green-u1: desaturate($green,15%);
$green-u2: desaturate($green,30%);
$green-u3: desaturate($green,45%);
$yellow: rgb(231, 214, 143);
$yellow: rgb(237, 189, 60);
$yellow-l1: tint($yellow,20%);
$yellow-l2: tint($yellow,40%);
$yellow-l3: tint($yellow,60%);
......@@ -149,8 +149,13 @@ $orange-u3: desaturate($orange,45%);
$shadow: rgba(0,0,0,0.2);
$shadow-l1: rgba(0,0,0,0.1);
$shadow-l2: rgba(0,0,0,0.05);
$shadow-d1: rgba(0,0,0,0.4);
// specific UI
$notification-height: ($baseline*10);
// colors - inherited
$baseFontColor: $gray-d2;
$offBlack: #3c3c3c;
......
@mixin bounce-in {
// studio animations & keyframes
// ====================
// rotate clockwise
@mixin rotateClockwise {
0% {
@include transform(rotate(0deg));
}
100% {
@include transform(rotate(360deg));
}
}
@-moz-keyframes rotateClockwise { @include rotateClockwise(); }
@-webkit-keyframes rotateClockwise { @include rotateClockwise(); }
@-o-keyframes rotateClockwise { @include rotateClockwise(); }
@keyframes rotateClockwise { @include rotateClockwise();}
@mixin anim-rotateClockwise($duration, $timing: ease-in-out, $count: 1, $delay: 0) {
@include animation-name(rotateClockwise);
@include animation-duration($duration);
@include animation-delay($delay);
@include animation-timing-function($timing);
@include animation-iteration-count($count);
@include animation-fill-mode(both);
}
// ====================
// notifications slide up
@mixin notificationsSlideUp {
0% {
@include transform(translateY(0));
}
90% {
@include transform(translateY(-($notification-height)));
}
100% {
@include transform(translateY(-($notification-height*0.99)));
}
}
@-moz-keyframes notificationsSlideUp { @include notificationsSlideUp(); }
@-webkit-keyframes notificationsSlideUp { @include notificationsSlideUp(); }
@-o-keyframes notificationsSlideUp { @include notificationsSlideUp(); }
@keyframes notificationsSlideUp { @include notificationsSlideUp();}
@mixin anim-notificationsSlideUp($duration, $timing: ease-in-out, $count: 1, $delay: 0) {
@include animation-name(notificationsSlideUp);
@include animation-duration($duration);
@include animation-delay($delay);
@include animation-timing-function($timing);
@include animation-iteration-count($count);
@include animation-fill-mode(both);
}
// ====================
// notifications slide down
@mixin notificationsSlideDown {
0% {
@include transform(translateY(-($notification-height*0.99)));
}
10% {
@include transform(translateY(-($notification-height)));
}
100% {
@include transform(translateY(0));
}
}
@-moz-keyframes notificationsSlideDown { @include notificationsSlideDown(); }
@-webkit-keyframes notificationsSlideDown { @include notificationsSlideDown(); }
@-o-keyframes notificationsSlideDown { @include notificationsSlideDown(); }
@keyframes notificationsSlideDown { @include notificationsSlideDown();}
@mixin anim-notificationsSlideDown($duration, $timing: ease-in-out, $count: 1, $delay: 0) {
@include animation-name(notificationsSlideDown);
@include animation-duration($duration);
@include animation-delay($delay);
@include animation-timing-function($timing);
@include animation-iteration-count($count);
@include animation-fill-mode(both);
}
// ====================
// notifications slide up then down
@mixin notificationsSlideUpDown {
0%, 100% {
@include transform(translateY(0));
}
15%, 85% {
@include transform(translateY(-($notification-height)));
}
20%, 80% {
@include transform(translateY(-($notification-height*0.99)));
}
}
@-moz-keyframes notificationsSlideUpDown { @include notificationsSlideUpDown(); }
@-webkit-keyframes notificationsSlideUpDown { @include notificationsSlideUpDown(); }
@-o-keyframes notificationsSlideUpDown { @include notificationsSlideUpDown(); }
@keyframes notificationsSlideUpDown { @include notificationsSlideUpDown();}
@mixin anim-notificationsSlideUpDown($duration, $timing: ease-in-out, $count: 1, $delay: 0) {
@include animation-name(notificationsSlideUpDown);
@include animation-duration($duration);
@include animation-delay($delay);
@include animation-timing-function($timing);
@include animation-iteration-count($count);
@include animation-fill-mode(both);
}
// ====================
// bounce in
@mixin bounceIn {
0% {
opacity: 0;
@include transform(scale(0.3));
}
50% {
opacity: 1;
@include transform(scale(1.05));
}
100% {
@include transform(scale(1));
}
}
@-moz-keyframes bounceIn { @include bounceIn(); }
@-webkit-keyframes bounceIn { @include bounceIn(); }
@-o-keyframes bounceIn { @include bounceIn(); }
@keyframes bounceIn { @include bounceIn();}
@mixin anim-bounceIn($duration, $timing: ease-in-out, $count: 1, $delay: 0) {
@include animation-name(bounceIn);
@include animation-duration($duration);
@include animation-delay($delay);
@include animation-timing-function($timing);
@include animation-iteration-count($count);
@include animation-fill-mode(both);
}
// ====================
// bounce in
@mixin bounceOut {
0% {
opacity: 0;
@include transform(scale(.3));
@include transform(scale(0.3));
}
50% {
......@@ -12,16 +171,32 @@
100% {
@include transform(scale(1));
}
0% {
@include transform(scale(1));
}
50% {
opacity: 1;
@include transform(scale(1.05));
}
100% {
opacity: 0;
@include transform(scale(0.3));
}
}
@-moz-keyframes bounce-in { @include bounce-in(); }
@-webkit-keyframes bounce-in { @include bounce-in(); }
@-o-keyframes bounce-in { @include bounce-in(); }
@keyframes bounce-in { @include bounce-in();}
@-moz-keyframes bounceOut { @include bounceOut(); }
@-webkit-keyframes bounceOut { @include bounceOut(); }
@-o-keyframes bounceOut { @include bounceOut(); }
@keyframes bounceOut { @include bounceOut();}
@mixin bounce-in-animation($duration, $timing: ease-in-out) {
@include animation-name(bounce-in);
@mixin anim-bounceOut($duration, $timing: ease-in-out, $count: 1, $delay: 0) {
@include animation-name(bounceOut);
@include animation-duration($duration);
@include animation-delay($delay);
@include animation-timing-function($timing);
@include animation-iteration-count($count);
@include animation-fill-mode(both);
}
\ No newline at end of file
......@@ -4,6 +4,7 @@
// bourbon libs and resets
@import 'bourbon/bourbon';
@import 'bourbon/addons/button';
@import "variables";
@import 'vendor/normalize';
@import 'reset';
......
......@@ -97,7 +97,7 @@
color: $blue;
&:hover, &:active {
background: $blue-l3;
background: $blue-l4;
color: $blue-s2;
}
......
......@@ -8,11 +8,11 @@ input[type="password"],
textarea.text {
padding: 6px 8px 8px;
@include box-sizing(border-box);
border: 1px solid $mediumGrey;
border: 1px solid $gray-l2;
border-radius: 2px;
@include linear-gradient($lightGrey, tint($lightGrey, 90%));
background-color: $lightGrey;
@include box-shadow(0 1px 2px rgba(0, 0, 0, .1) inset);
@include linear-gradient($gray-l5, $white);
background-color: $gray-l5;
@include box-shadow(inset 0 1px 2px $shadow-l1);
font-family: 'Open Sans', sans-serif;
font-size: 11px;
color: $baseFontColor;
......@@ -21,7 +21,7 @@ textarea.text {
&::-webkit-input-placeholder,
&:-moz-placeholder,
&:-ms-input-placeholder {
color: #979faf;
color: $gray-l2;
}
&:focus {
......@@ -30,7 +30,72 @@ textarea.text {
}
}
// forms - specific
// ====================
// forms - fields - not editable
.field.is-not-editable {
& label.is-focused {
color: $gray-d2;
}
label, input, textarea {
pointer-events: none;
}
}
// ====================
// field with error
.field.error {
input, textarea {
border-color: $red;
}
}
// ====================
// forms - additional UI
form {
.note {
@include box-sizing(border-box);
.title {
}
.copy {
}
// note with actions
&.has-actions {
@include clearfix();
.title {
}
.copy {
}
.list-actions {
}
}
}
.note-promotion {
}
}
// ====================
// forms - grandfathered
input.search {
padding: 6px 15px 8px 30px;
@include box-sizing(border-box);
......
......@@ -5,12 +5,12 @@
margin: 0;
padding: $baseline;
border-bottom: 1px solid $gray;
@include box-shadow(0 1px 5px 0 rgba(0,0,0, 0.1));
@include box-shadow(0 1px 5px 0 rgba(0,0,0, 0.2));
background: $white;
height: 76px;
position: relative;
width: 100%;
z-index: 10;
z-index: 1000;
a {
color: $baseFontColor;
......
......@@ -4,7 +4,7 @@
body.signup, body.signin {
.wrapper-content {
margin: 0;
margin: ($baseline*1.5) 0 0 0;
padding: 0 $baseline;
position: relative;
width: 100%;
......
......@@ -147,7 +147,7 @@ body.course.settings {
}
label {
@include font-size(14);
@extend .t-copy-sub1;
@include transition(color, 0.15s, ease-in-out);
margin: 0 0 ($baseline/4) 0;
font-weight: 400;
......@@ -238,33 +238,36 @@ body.course.settings {
}
}
// not editable fields
.field.is-not-editable {
& label.is-focused {
color: $gray-d2;
}
}
// field with error
.field.error {
input, textarea {
border-color: $red;
}
}
// specific fields - basic
&.basic {
.list-input {
@include clearfix();
padding: 0 ($baseline/2);
.field {
margin-bottom: 0;
}
}
// course details that should appear more like content than elements to change
.field.is-not-editable {
label {
}
input, textarea {
@extend .t-copy-lead1;
@include box-shadow(none);
border: none;
background: none;
padding: 0;
margin: 0;
font-weight: 600;
}
}
#field-course-organization {
float: left;
width: flex-grid(2, 9);
......@@ -281,6 +284,58 @@ body.course.settings {
float: left;
width: flex-grid(5, 9);
}
// course link note
.note-promotion-courseURL {
@include box-shadow(0 2px 1px $shadow-l1);
@include border-radius(($baseline/5));
margin-top: ($baseline*1.5);
border: 1px solid $gray-l2;
padding: ($baseline/2) 0 0 0;
.title {
@extend .t-copy-sub1;
margin: 0 0 ($baseline/10) 0;
padding: 0 ($baseline/2);
.tip {
display: inline;
margin-left: ($baseline/4);
}
}
.copy {
padding: 0 ($baseline/2) ($baseline/2) ($baseline/2);
.link-courseURL {
@extend .t-copy-lead1;
&:hover {
}
}
}
.list-actions {
@include box-shadow(inset 0 1px 1px $shadow-l1);
border-top: 1px solid $gray-l2;
padding: ($baseline/2);
background: $gray-l5;
.action-primary {
@include blue-button();
@include font-size(13);
font-weight: 600;
.icon {
@extend .t-icon;
@include font-size(16);
display: inline-block;
vertical-align: middle;
}
}
}
}
}
// specific fields - schedule
......
......@@ -54,8 +54,13 @@
document.location.protocol + '//www.youtube.com/player_api">\x3C/script>');
</script>
<!-- view -->
<div class="wrapper wrapper-view">
<%include file="widgets/header.html" />
<%block name="view_alerts"></%block>
<%block name="view_banners"></%block>
<%block name="content"></%block>
% if user.is_authenticated():
......@@ -64,6 +69,11 @@
<%include file="widgets/footer.html" />
<%include file="widgets/tender.html" />
<%block name="view_notifications"></%block>
</div>
<%block name="view_prompts"></%block>
<%block name="jsextra"></%block>
</body>
......
......@@ -83,7 +83,19 @@ from contentstore import utils
<input title="This field is disabled: this information cannot be changed." type="text" class="long" id="course-name" value="[Course Name]" readonly />
</li>
</ol>
<span class="tip tip-stacked">These are used in <a rel="external" href="${utils.get_lms_link_for_about_page(course_location)}" />your course URL</a>, and cannot be changed</span>
<div class="note note-promotion note-promotion-courseURL has-actions">
<h3 class="title">Course Summary Page <span class="tip">(for student enrollment and access)</span></h3>
<div class="copy">
<p><a class="link-courseURL" rel="external" href="https:${utils.get_lms_link_for_about_page(course_location)}" />https:${utils.get_lms_link_for_about_page(course_location)}</a></p>
</div>
<ul class="list-actions">
<li class="action-item">
<a title="Send a note to students via email" href="mailto:someone@domain.com?Subject=Enroll%20in%20${context_course.display_name_with_default}&body=The%20course%20&quot;${context_course.display_name_with_default}&quot;,%20provided%20by%20edX,%20is%20open%20for%20enrollment.%20Please%20navigate%20to%20this%20course%20at%20https:${utils.get_lms_link_for_about_page(course_location)}%20to%20enroll." class="action action-primary"><i class="ss-icon icon ss-symbolicons-standard icon icon-inline icon-announcement">&#x2709;</i> Invite your students</a>
</li>
</ul>
</div>
</section>
<hr class="divide" />
......@@ -167,7 +179,7 @@ from contentstore import utils
<li class="field text" id="field-course-overview">
<label for="course-overview">Course Overview</label>
<textarea class="tinymce text-editor" id="course-overview"></textarea>
<span class="tip tip-stacked">Introductions, prerequisites, FAQs that are used on <a href="${utils.get_lms_link_for_about_page(course_location)}">your course summary page</a></span>
<span class="tip tip-stacked">Introductions, prerequisites, FAQs that are used on <a class="link-courseURL" rel="external" href="${utils.get_lms_link_for_about_page(course_location)}">your course summary page</a></span>
</li>
<li class="field video" id="field-course-introduction-video">
......@@ -175,7 +187,6 @@ from contentstore import utils
<div class="input input-existing">
<div class="current current-course-introduction-video">
<iframe width="618" height="350" src="" frameborder="0" allowfullscreen></iframe>
</div>
<div class="actions">
<a href="#" class="remove-item remove-course-introduction-video remove-video-data"><span class="delete-icon"></span> Delete Current Video</a>
......
......@@ -42,13 +42,17 @@ editor.render();
</%block>
<%block name="content">
<div class="wrapper-content wrapper">
<section class="content">
<header class="page">
<div class="wrapper-mast wrapper">
<header class="mast has-subtitle">
<div class="title">
<span class="title-sub">Settings</span>
<h1 class="title-1">Advanced Settings</h1>
</div>
</header>
</div>
<div class="wrapper-content wrapper">
<section class="content">
<article class="content-primary" role="main">
<form id="settings_advanced" class="settings-advanced" method="post" action="">
......@@ -100,22 +104,60 @@ editor.render();
</aside>
</section>
</div>
</%block>
<%block name="view_notifications">
<!-- notification: change has been made and a save is needed -->
<div class="wrapper wrapper-notification wrapper-notification-warning">
<div class="notification warning">
<div class="copy">
<div class="wrapper wrapper-notification wrapper-notification-warning" aria-hidden="true" role="dialog" aria-labelledby="notification-changesMade-title" aria-describedby="notification-changesMade-description">
<div class="notification warning has-actions">
<i class="ss-icon ss-symbolicons-block icon icon-warning">&#x26A0;</i>
<p><strong>Note: </strong>Your changes will not take effect until you <strong>save your
progress</strong>. Take care with policy value formatting, as validation is <strong>not implemented</strong>.</p>
<div class="copy">
<h2 class="title title-3" id="notification-changesMade-title">You've Made Some Changes</h2>
<p id="notification-changesMade-description">Your changes will not take effect until you <strong>save your progress</strong>. Take care with key and value formatting, as validation is <strong>not implemented</strong>.</p>
</div>
<div class="actions">
<nav class="nav-actions">
<h3 class="sr">Notification Actions</h3>
<ul>
<li><a href="#" class="save-button">Save</a></li>
<li><a href="#" class="cancel-button">Cancel</a></li>
<li class="nav-item">
<a href="" class="action-primary save-button">Save Changes</a>
</li>
<li class="nav-item">
<a href="" class="action-secondary cancel-button">Cancel</a>
</li>
</ul>
</nav>
</div>
</div>
</%block>
<%block name="view_alerts">
<!-- alert: save confirmed with close -->
<div class="wrapper wrapper-alert wrapper-alert-confirmation" role="status">
<div class="alert confirmation">
<i class="ss-icon ss-symbolicons-standard icon icon-confirmation">&#x2713;</i>
<div class="copy">
<h2 class="title title-3">Your policy changes have been saved.</h2>
<p>Please note that validation of your policy key and value pairs is not currently in place yet. If you are having difficulties, please review your policy pairs.</p>
</div>
<a href="" rel="view" class="action action-alert-close">
<i class="ss-icon ss-symbolicons-block icon icon-close">&#x2421;</i>
<span class="label">close alert</span>
</a>
</div>
</div>
<!-- alert: error -->
<div class="wrapper wrapper-alert wrapper-alert-error" role="status">
<div class="alert error">
<i class="ss-icon ss-symbolicons-block icon icon-error">&#x26A0;</i>
<div class="copy">
<h2 class="title title-3">There was an error saving your information</h2>
<p>Please see the error below and correct it to ensure there are no problems in rendering your course.</p>
</div>
</div>
</div>
......
......@@ -97,7 +97,7 @@ from contentstore import utils
<ol class="list-input">
<li class="field text" id="field-course-grading-graceperiod">
<label for="course-grading-graceperiod">Grace Period on Deadline:</label>
<input type="text" class="short time" id="course-grading-graceperiod" value="0:00" placeholder="e.g. 10 minutes">
<input type="text" class="short time" id="course-grading-graceperiod" value="0:00" placeholder="HH:MM" autocomplete="off" />
<span class="tip tip-inline">Leeway on due dates</span>
</li>
</ol>
......
......@@ -16,7 +16,6 @@
});
$(document).ready(function() {
$('body').addClass('js');
// tabs
$('.tab-group').tabs();
......
<%include file="metadata-edit.html" />
......@@ -12,7 +12,7 @@
<form id="hls-form" enctype="multipart/form-data">
<section class="source-edit">
<textarea name="" data-metadata-name="source_code" class="source-edit-box hls-data" rows="8" cols="40">${metadata['source_code']|h}</textarea>
<textarea name="" data-metadata-name="source_code" class="source-edit-box hls-data" rows="8" cols="40">${editable_metadata_fields['source_code']|h}</textarea>
</section>
<div class="submit">
<button type="reset" class="hls-compile">Save &amp; Compile to edX XML</button>
......
......@@ -116,6 +116,8 @@ urlpatterns += (
url(r'^logout$', 'student.views.logout_user', name='logout'),
# static/proof-of-concept views
url(r'^ux-alerts$', 'contentstore.views.ux_alerts', name='ux-alerts')
)
js_info_dict = {
......
......@@ -2,17 +2,17 @@ from student.models import (User, UserProfile, Registration,
CourseEnrollmentAllowed, CourseEnrollment)
from django.contrib.auth.models import Group
from datetime import datetime
from factory import Factory, SubFactory
from factory import DjangoModelFactory, Factory, SubFactory, PostGenerationMethodCall
from uuid import uuid4
class GroupFactory(Factory):
class GroupFactory(DjangoModelFactory):
FACTORY_FOR = Group
name = 'staff_MITx/999/Robot_Super_Course'
class UserProfileFactory(Factory):
class UserProfileFactory(DjangoModelFactory):
FACTORY_FOR = UserProfile
user = None
......@@ -23,19 +23,20 @@ class UserProfileFactory(Factory):
goals = 'World domination'
class RegistrationFactory(Factory):
class RegistrationFactory(DjangoModelFactory):
FACTORY_FOR = Registration
user = None
activation_key = uuid4().hex
class UserFactory(Factory):
class UserFactory(DjangoModelFactory):
FACTORY_FOR = User
username = 'robot'
email = 'robot+test@edx.org'
password = 'test'
password = PostGenerationMethodCall('set_password',
'test')
first_name = 'Robot'
last_name = 'Test'
is_staff = False
......@@ -45,14 +46,18 @@ class UserFactory(Factory):
date_joined = datetime(2011, 1, 1)
class CourseEnrollmentFactory(Factory):
class AdminFactory(UserFactory):
is_staff = True
class CourseEnrollmentFactory(DjangoModelFactory):
FACTORY_FOR = CourseEnrollment
user = SubFactory(UserFactory)
course_id = 'edX/toy/2012_Fall'
class CourseEnrollmentAllowedFactory(Factory):
class CourseEnrollmentAllowedFactory(DjangoModelFactory):
FACTORY_FOR = CourseEnrollmentAllowed
email = 'test@edx.org'
......
......@@ -1079,7 +1079,7 @@ def test_center_login(request):
# which contains the error code describing the exceptional condition.
def makeErrorURL(error_url, error_code):
log.error("generating error URL with error code {}".format(error_code))
return "{}?code={}".format(error_url, error_code);
return "{}?code={}".format(error_url, error_code)
# get provided error URL, which will be used as a known prefix for returning error messages to the
# Pearson shell.
......@@ -1088,7 +1088,7 @@ def test_center_login(request):
# TODO: check that the parameters have not been tampered with, by comparing the code provided by Pearson
# with the code we calculate for the same parameters.
if 'code' not in request.POST:
return HttpResponseRedirect(makeErrorURL(error_url, "missingSecurityCode"));
return HttpResponseRedirect(makeErrorURL(error_url, "missingSecurityCode"))
code = request.POST.get("code")
# calculate SHA for query string
......@@ -1096,7 +1096,7 @@ def test_center_login(request):
if 'clientCandidateID' not in request.POST:
return HttpResponseRedirect(makeErrorURL(error_url, "missingClientCandidateID"));
return HttpResponseRedirect(makeErrorURL(error_url, "missingClientCandidateID"))
client_candidate_id = request.POST.get("clientCandidateID")
# TODO: check remaining parameters, and maybe at least log if they're not matching
......@@ -1109,7 +1109,7 @@ def test_center_login(request):
testcenteruser = TestCenterUser.objects.get(client_candidate_id=client_candidate_id)
except TestCenterUser.DoesNotExist:
log.error("not able to find demographics for cand ID {}".format(client_candidate_id))
return HttpResponseRedirect(makeErrorURL(error_url, "invalidClientCandidateID"));
return HttpResponseRedirect(makeErrorURL(error_url, "invalidClientCandidateID"))
# find testcenter_registration that matches the provided exam code:
# Note that we could rely in future on either the registrationId or the exam code,
......@@ -1120,7 +1120,7 @@ def test_center_login(request):
# so instead of "missingExamSeriesCode", we use a valid one that is
# inaccurate but at least distinct. (Sigh.)
log.error("missing exam series code for cand ID {}".format(client_candidate_id))
return HttpResponseRedirect(makeErrorURL(error_url, "missingPartnerID"));
return HttpResponseRedirect(makeErrorURL(error_url, "missingPartnerID"))
exam_series_code = request.POST.get('vueExamSeriesCode')
# special case for supporting test user:
if client_candidate_id == "edX003671291147" and exam_series_code != '6002x001':
......@@ -1130,7 +1130,7 @@ def test_center_login(request):
registrations = TestCenterRegistration.objects.filter(testcenter_user=testcenteruser, exam_series_code=exam_series_code)
if not registrations:
log.error("not able to find exam registration for exam {} and cand ID {}".format(exam_series_code, client_candidate_id))
return HttpResponseRedirect(makeErrorURL(error_url, "noTestsAssigned"));
return HttpResponseRedirect(makeErrorURL(error_url, "noTestsAssigned"))
# TODO: figure out what to do if there are more than one registrations....
# for now, just take the first...
......@@ -1140,11 +1140,11 @@ def test_center_login(request):
course = course_from_id(course_id) # assume it will be found....
if not course:
log.error("not able to find course from ID {} for cand ID {}".format(course_id, client_candidate_id))
return HttpResponseRedirect(makeErrorURL(error_url, "incorrectCandidateTests"));
return HttpResponseRedirect(makeErrorURL(error_url, "incorrectCandidateTests"))
exam = course.get_test_center_exam(exam_series_code)
if not exam:
log.error("not able to find exam {} for course ID {} and cand ID {}".format(exam_series_code, course_id, client_candidate_id))
return HttpResponseRedirect(makeErrorURL(error_url, "incorrectCandidateTests"));
return HttpResponseRedirect(makeErrorURL(error_url, "incorrectCandidateTests"))
location = exam.exam_url
log.info("proceeding with test of cand {} on exam {} for course {}: URL = {}".format(client_candidate_id, exam_series_code, course_id, location))
......@@ -1152,7 +1152,7 @@ def test_center_login(request):
timelimit_descriptor = modulestore().get_instance(course_id, Location(location))
if not timelimit_descriptor:
log.error("cand {} on exam {} for course {}: descriptor not found for location {}".format(client_candidate_id, exam_series_code, course_id, location))
return HttpResponseRedirect(makeErrorURL(error_url, "missingClientProgram"));
return HttpResponseRedirect(makeErrorURL(error_url, "missingClientProgram"))
timelimit_module_cache = ModelDataCache.cache_for_descriptor_descendents(course_id, testcenteruser.user,
timelimit_descriptor, depth=None)
......@@ -1160,11 +1160,11 @@ def test_center_login(request):
timelimit_module_cache, course_id, position=None)
if not timelimit_module.category == 'timelimit':
log.error("cand {} on exam {} for course {}: non-timelimit module at location {}".format(client_candidate_id, exam_series_code, course_id, location))
return HttpResponseRedirect(makeErrorURL(error_url, "missingClientProgram"));
return HttpResponseRedirect(makeErrorURL(error_url, "missingClientProgram"))
if timelimit_module and timelimit_module.has_ended:
log.warning("cand {} on exam {} for course {}: test already over at {}".format(client_candidate_id, exam_series_code, course_id, timelimit_module.ending_at))
return HttpResponseRedirect(makeErrorURL(error_url, "allTestsTaken"));
return HttpResponseRedirect(makeErrorURL(error_url, "allTestsTaken"))
# check if we need to provide an accommodation:
time_accommodation_mapping = {'ET12ET' : 'ADDHALFTIME',
......
from lettuce import before, after, world
from splinter.browser import Browser
from logging import getLogger
from django.core.management import call_command
from django.conf import settings
# Let the LMS and CMS do their one-time setup
# For example, setting up mongo caches
......@@ -10,18 +12,14 @@ from cms import one_time_startup
logger = getLogger(__name__)
logger.info("Loading the lettuce acceptance testing terrain file...")
from django.core.management import call_command
@before.harvest
def initial_setup(server):
'''
Launch the browser once before executing the tests
'''
# Launch the browser app (choose one of these below)
world.browser = Browser('chrome')
# world.browser = Browser('phantomjs')
# world.browser = Browser('firefox')
browser_driver = getattr(settings, 'LETTUCE_BROWSER', 'chrome')
world.browser = Browser(browser_driver)
@before.each_scenario
......@@ -34,6 +32,15 @@ def reset_data(scenario):
call_command('flush', interactive=False)
@after.each_scenario
def screenshot_on_error(scenario):
'''
Save a screenshot to help with debugging
'''
if scenario.failed:
world.browser.driver.save_screenshot('/tmp/last_failed_scenario.png')
@after.all
def teardown_browser(total):
'''
......
......@@ -132,6 +132,8 @@ def i_am_logged_in(step):
world.create_user('robot')
world.log_in('robot', 'test')
world.browser.visit(django_url('/'))
# You should not see the login link
assert_equals(world.browser.find_by_css('a#login'), [])
@step(u'I am an edX user$')
......
......@@ -105,8 +105,12 @@ def add_histogram(get_html, module, user):
return get_html()
module_id = module.id
if module.descriptor.has_score:
histogram = grade_histogram(module_id)
render_histogram = len(histogram) > 0
else:
histogram = None
render_histogram = False
if settings.MITX_FEATURES.get('ENABLE_LMS_MIGRATION'):
[filepath, filename] = getattr(module.descriptor, 'xml_attributes', {}).get('filename', ['', None])
......
......@@ -150,8 +150,8 @@ class InputTypeBase(object):
## we can swap this around in the future if there's a more logical
## order.
self.id = state.get('id', xml.get('id'))
if self.id is None:
self.input_id = state.get('id', xml.get('id'))
if self.input_id is None:
raise ValueError("input id state is None. xml is {0}".format(
etree.tostring(xml)))
......@@ -249,7 +249,7 @@ class InputTypeBase(object):
and don't need to override this method.
"""
context = {
'id': self.id,
'id': self.input_id,
'value': self.value,
'status': self.status,
'msg': self.msg,
......@@ -457,8 +457,21 @@ class TextLine(InputTypeBase):
"""
A text line input. Can do math preview if "math"="1" is specified.
If the hidden attribute is specified, the textline is hidden and the input id is stored in a div with name equal
to the value of the hidden attribute. This is used e.g. for embedding simulations turned into questions.
If "trailing_text" is set to a value, then the textline will be shown with
the value after the text input, and before the checkmark or any input-specific
feedback. HTML will not work, but properly escaped HTML characters will. This
feature is useful if you would like to specify a specific type of units for the
text input.
If the hidden attribute is specified, the textline is hidden and the input id
is stored in a div with name equal to the value of the hidden attribute. This
is used e.g. for embedding simulations turned into questions.
Example:
<texline math="1" trailing_text="m/s" />
This example will render out a text line with a math preview and the text 'm/s'
after the end of the text line.
"""
template = "textline.html"
......@@ -483,6 +496,7 @@ class TextLine(InputTypeBase):
Attribute('dojs', None, render=False),
Attribute('preprocessorClassName', None, render=False),
Attribute('preprocessorSrc', None, render=False),
Attribute('trailing_text', ''),
]
def setup(self):
......@@ -609,7 +623,6 @@ class CodeInput(InputTypeBase):
self.queue_len = self.msg
self.msg = self.submitted_msg
def setup(self):
''' setup this input type '''
self.setup_code_response_rendering()
......@@ -655,6 +668,8 @@ class MatlabInput(CodeInput):
# Check if problem has been queued
self.queuename = 'matlab'
self.queue_msg = ''
# this is only set if we don't have a graded response
# the graded response takes precedence
if 'queue_msg' in self.input_state and self.status in ['queued', 'incomplete', 'unsubmitted']:
self.queue_msg = self.input_state['queue_msg']
if 'queuestate' in self.input_state and self.input_state['queuestate'] == 'queued':
......@@ -662,7 +677,6 @@ class MatlabInput(CodeInput):
self.queue_len = 1
self.msg = self.plot_submitted_msg
def handle_ajax(self, dispatch, get):
'''
Handle AJAX calls directed to this input
......@@ -671,7 +685,8 @@ class MatlabInput(CodeInput):
- dispatch (str) - indicates how we want this ajax call to be handled
- get (dict) - dictionary of key-value pairs that contain useful data
Returns:
dict - 'success' - whether or not we successfully queued this submission
- 'message' - message to be rendered in case of error
'''
if dispatch == 'plot':
......@@ -699,11 +714,23 @@ class MatlabInput(CodeInput):
self.input_state['queuestate'] = None
self.input_state['queuekey'] = None
def button_enabled(self):
""" Return whether or not we want the 'Test Code' button visible
Right now, we only want this button to show up when a problem has not been
checked.
"""
if self.status in ['correct', 'incorrect']:
return False
else:
return True
def _extra_context(self):
''' Set up additional context variables'''
extra_context = {
'queue_len': str(self.queue_len),
'queue_msg': self.queue_msg
'queue_msg': self.queue_msg,
'button_enabled': self.button_enabled(),
}
return extra_context
......@@ -724,7 +751,6 @@ class MatlabInput(CodeInput):
msg = result['msg']
return msg
def _plot_data(self, get):
'''
AJAX handler for the plot button
......@@ -748,16 +774,11 @@ class MatlabInput(CodeInput):
anonymous_student_id = self.system.anonymous_student_id
queuekey = xqueue_interface.make_hashkey(str(self.system.seed) + qtime +
anonymous_student_id +
self.id)
self.input_id)
xheader = xqueue_interface.make_xheader(
lms_callback_url = callback_url,
lms_key = queuekey,
queue_name = self.queuename)
# save the input state
self.input_state['queuekey'] = queuekey
self.input_state['queuestate'] = 'queued'
lms_callback_url=callback_url,
lms_key=queuekey,
queue_name=self.queuename)
# construct xqueue body
student_info = {'anonymous_student_id': anonymous_student_id,
......@@ -767,7 +788,11 @@ class MatlabInput(CodeInput):
'student_response': response}
(error, msg) = qinterface.send_to_queue(header=xheader,
body = json.dumps(contents))
body=json.dumps(contents))
# save the input state if successful
if error == 0:
self.input_state['queuekey'] = queuekey
self.input_state['queuestate'] = 'queued'
return {'success': error == 0, 'message': msg}
......
......@@ -33,9 +33,11 @@
${queue_msg|n}
</div>
% if button_enabled:
<div class="plot-button">
<input type="button" class="save" name="plot-button" id="plot_${id}" value="Plot" />
<input type="button" class="save" name="plot-button" id="plot_${id}" value="Run Code" />
</div>
%endif
<script>
// Note: We need to make the area follow the CodeMirror for this to work.
......@@ -91,7 +93,7 @@
window.location.reload();
}
else {
gentle_alert(problem_elt, msg);
gentle_alert(problem_elt, response.message);
}
}
......@@ -102,7 +104,7 @@
{'submission': submission}, plot_callback);
}
else {
gentle_alert(problem_elt, msg);
gentle_alert(problem_elt, response.message);
}
}
......
......@@ -31,6 +31,7 @@
style="display:none;"
% endif
/>
${trailing_text | h}
<p class="status">
% if status == 'unsubmitted':
......
......@@ -156,6 +156,7 @@ class CapaHtmlRenderTest(unittest.TestCase):
'hidden': False,
'do_math': False,
'id': '1_2_1',
'trailing_text': '',
'size': None}
expected_solution_context = {'id': '1_solution_1'}
......
......@@ -50,9 +50,7 @@ class Test_DragAndDrop_Grade(unittest.TestCase):
correct_answer = [
{
'draggables': ['p'],
'targets': [
'p_l', 'p_r'
],
'targets': ['p_l', 'p_r'],
'rule': 'anyof'
},
{
......@@ -201,8 +199,7 @@ class Test_DragAndDrop_Grade(unittest.TestCase):
'left_side_tagret[molecule][p_target][p][1]',
'left_side_tagret[molecule][p_target][p][3]',
'right_side_tagret[molecule][p_target][p][1]',
'right_side_tagret[molecule][p_target][p][3]'
],
'right_side_tagret[molecule][p_target][p][3]'],
'rule': 'unordered_equal'
}
]
......@@ -290,7 +287,6 @@ class Test_DragAndDrop_Grade(unittest.TestCase):
correct_answer = []
self.assertTrue(draganddrop.grade(user_input, correct_answer))
def test_targets_false(self):
user_input = '[{"1": "t1"}, \
{"name_with_icon": "t2"}]'
......@@ -300,15 +296,13 @@ class Test_DragAndDrop_Grade(unittest.TestCase):
def test_multiple_images_per_target_true(self):
user_input = '[{"1": "t1"}, {"name_with_icon": "t2"}, \
{"2": "t1"}]'
correct_answer = {'1': 't1', 'name_with_icon': 't2',
'2': 't1'}
correct_answer = {'1': 't1', 'name_with_icon': 't2', '2': 't1'}
self.assertTrue(draganddrop.grade(user_input, correct_answer))
def test_multiple_images_per_target_false(self):
user_input = '[{"1": "t1"}, {"name_with_icon": "t2"}, \
{"2": "t1"}]'
correct_answer = {'1': 't2', 'name_with_icon': 't2',
'2': 't1'}
correct_answer = {'1': 't2', 'name_with_icon': 't2', '2': 't1'}
self.assertFalse(draganddrop.grade(user_input, correct_answer))
def test_targets_and_positions(self):
......@@ -460,7 +454,8 @@ class Test_DragAndDrop_Grade(unittest.TestCase):
'draggables': ['3'],
'targets': ['target6'],
'rule': 'anyof'
}]
}
]
self.assertTrue(draganddrop.grade(user_input, correct_answer))
def test_reuse_draggable_with_mupliples(self):
......@@ -483,7 +478,8 @@ class Test_DragAndDrop_Grade(unittest.TestCase):
'draggables': ['3'],
'targets': ['target6'],
'rule': 'anyof'
}]
}
]
self.assertTrue(draganddrop.grade(user_input, correct_answer))
def test_reuse_many_draggable_with_mupliples(self):
......@@ -512,7 +508,8 @@ class Test_DragAndDrop_Grade(unittest.TestCase):
'draggables': ['3'],
'targets': ['target6'],
'rule': 'anyof'
}]
}
]
self.assertTrue(draganddrop.grade(user_input, correct_answer))
def test_reuse_many_draggable_with_mupliples_wrong(self):
......@@ -567,7 +564,8 @@ class Test_DragAndDrop_Grade(unittest.TestCase):
'draggables': ['c'],
'targets': ['target3', 'target6', 'target9'],
'rule': 'unordered_equal'
}]
}
]
self.assertFalse(draganddrop.grade(user_input, correct_answer))
def test_label_10_targets_with_a_b_c_(self):
......@@ -591,7 +589,8 @@ class Test_DragAndDrop_Grade(unittest.TestCase):
'draggables': ['c'],
'targets': ['target3', 'target6', 'target9'],
'rule': 'unordered_equal'
}]
}
]
self.assertTrue(draganddrop.grade(user_input, correct_answer))
def test_label_10_targets_with_a_b_c_multiple(self):
......@@ -615,7 +614,8 @@ class Test_DragAndDrop_Grade(unittest.TestCase):
'draggables': ['c', 'c', 'c'],
'targets': ['target3', 'target6', 'target9'],
'rule': 'anyof+number'
}]
}
]
self.assertTrue(draganddrop.grade(user_input, correct_answer))
def test_label_10_targets_with_a_b_c_multiple_false(self):
......@@ -639,7 +639,8 @@ class Test_DragAndDrop_Grade(unittest.TestCase):
'draggables': ['c', 'c', 'c'],
'targets': ['target3', 'target6', 'target9'],
'rule': 'anyof+number'
}]
}
]
self.assertFalse(draganddrop.grade(user_input, correct_answer))
def test_label_10_targets_with_a_b_c_reused(self):
......@@ -663,7 +664,8 @@ class Test_DragAndDrop_Grade(unittest.TestCase):
'draggables': ['c', 'c', 'c'],
'targets': ['target3', 'target6', 'target9'],
'rule': 'unordered_equal+number'
}]
}
]
self.assertTrue(draganddrop.grade(user_input, correct_answer))
def test_label_10_targets_with_a_b_c_reused_false(self):
......@@ -687,7 +689,8 @@ class Test_DragAndDrop_Grade(unittest.TestCase):
'draggables': ['c', 'c', 'c'],
'targets': ['target3', 'target6', 'target9'],
'rule': 'unordered_equal+number'
}]
}
]
self.assertFalse(draganddrop.grade(user_input, correct_answer))
def test_mixed_reuse_and_not_reuse(self):
......@@ -705,7 +708,8 @@ class Test_DragAndDrop_Grade(unittest.TestCase):
'draggables': ['c'],
'targets': ['target3'],
'rule': 'exact'
}]
}
]
self.assertTrue(draganddrop.grade(user_input, correct_answer))
def test_mixed_reuse_and_not_reuse_number(self):
......@@ -722,7 +726,8 @@ class Test_DragAndDrop_Grade(unittest.TestCase):
'draggables': ['c'],
'targets': ['target3'],
'rule': 'exact'
}]
}
]
self.assertTrue(draganddrop.grade(user_input, correct_answer))
def test_mixed_reuse_and_not_reuse_number_false(self):
......@@ -739,7 +744,8 @@ class Test_DragAndDrop_Grade(unittest.TestCase):
'draggables': ['c'],
'targets': ['target3'],
'rule': 'exact'
}]
}
]
self.assertFalse(draganddrop.grade(user_input, correct_answer))
def test_alternative_correct_answer(self):
......@@ -828,8 +834,7 @@ def suite():
testcases = [Test_PositionsCompare,
Test_DragAndDrop_Populate,
Test_DragAndDrop_Grade,
Test_DraAndDrop_Compare_Positions
]
Test_DraAndDrop_Compare_Positions]
suites = []
for testcase in testcases:
suites.append(unittest.TestLoader().loadTestsFromTestCase(testcase))
......
......@@ -6,7 +6,7 @@ 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.core import String, Scope, Object, BlockScope
from xblock.core import String, Scope, Object
DEFAULT = "_DEFAULT_GROUP"
......
import logging
from lxml import etree
from pkg_resources import resource_string, resource_listdir
from pkg_resources import resource_string
from xmodule.x_module import XModule
from xmodule.raw_module import RawDescriptor
from xmodule.contentstore.content import StaticContent
from xblock.core import Scope, String
log = logging.getLogger(__name__)
......@@ -25,7 +24,6 @@ class AnnotatableModule(AnnotatableFields, XModule):
css = {'scss': [resource_string(__name__, 'css/annotatable/display.scss')]}
icon_class = 'annotatable'
def __init__(self, *args, **kwargs):
XModule.__init__(self, *args, **kwargs)
......
......@@ -99,6 +99,7 @@ class CapaFields(object):
seed = StringyInteger(help="Random seed for this student", scope=Scope.user_state)
weight = StringyFloat(help="How much to weight this problem by", scope=Scope.settings)
markdown = String(help="Markdown source of this module", scope=Scope.settings)
source_code = String(help="Source code for LaTeX and Word problems. This feature is not well-supported.", scope=Scope.settings)
class CapaModule(CapaFields, XModule):
......
......@@ -14,7 +14,7 @@ from xmodule.open_ended_grading_classes.xblock_field_types import StringyFloat
log = logging.getLogger("mitx.courseware")
V1_SETTINGS_ATTRIBUTES = ["display_name", "attempts", "is_graded", "accept_file_upload",
"skip_spelling_checks", "due", "graceperiod"]
"skip_spelling_checks", "due", "graceperiod", "weight"]
V1_STUDENT_ATTRIBUTES = ["current_task_number", "task_states", "state",
"student_attempts", "ready_to_reset"]
......@@ -219,5 +219,5 @@ class CombinedOpenEndedDescriptor(CombinedOpenEndedFields, RawDescriptor):
stores_state = True
has_score = True
always_recalculate_grades=True
always_recalculate_grades = True
template_dir_name = "combinedopenended"
......@@ -10,7 +10,7 @@ from pkg_resources import resource_string
from xmodule.x_module import XModule
from xmodule.modulestore import Location
from xmodule.seq_module import SequenceDescriptor
from xblock.core import String, Scope, List
from xblock.core import Scope, List
from xmodule.modulestore.exceptions import ItemNotFoundError
......@@ -60,7 +60,6 @@ class ConditionalModule(ConditionalFields, XModule):
js = {'coffee': [resource_string(__name__, 'js/src/javascript_loader.coffee'),
resource_string(__name__, 'js/src/conditional/display.coffee'),
resource_string(__name__, 'js/src/collapsible.coffee'),
]}
js_module_name = "Conditional"
......@@ -82,8 +81,7 @@ class ConditionalModule(ConditionalFields, XModule):
xml_value = self.descriptor.xml_attributes.get(xml_attr)
if xml_value:
return xml_value, attr_name
raise Exception('Error in conditional module: unknown condition "%s"'
% xml_attr)
raise Exception('Error in conditional module: unknown condition "%s"' % xml_attr)
def is_condition_satisfied(self):
self.required_modules = [self.system.get_module(descriptor) for
......@@ -94,9 +92,13 @@ class ConditionalModule(ConditionalFields, XModule):
if xml_value and self.required_modules:
for module in self.required_modules:
if not hasattr(module, attr_name):
raise Exception('Error in conditional module: \
required module {module} has no {module_attr}'.format(
module=module, module_attr=attr_name))
# We don't throw an exception here because it is possible for
# the descriptor of a required module to have a property but
# for the resulting module to be a (flavor of) ErrorModule.
# So just log and return false.
log.warn('Error in conditional module: \
required module {module} has no {module_attr}'.format(module=module, module_attr=attr_name))
return False
attr = getattr(module, attr_name)
if callable(attr):
......@@ -139,7 +141,6 @@ class ConditionalModule(ConditionalFields, XModule):
def get_icon_class(self):
new_class = 'other'
if self.is_condition_satisfied():
# HACK: This shouldn't be hard-coded to two types
# OBSOLETE: This obsoletes 'type'
class_priority = ['video', 'problem']
......@@ -163,7 +164,6 @@ class ConditionalDescriptor(ConditionalFields, SequenceDescriptor):
stores_state = True
has_score = False
@staticmethod
def parse_sources(xml_element, system, return_descriptor=False):
"""Parse xml_element 'sources' attr and:
......
......@@ -9,6 +9,7 @@ import StringIO
from xmodule.modulestore import Location
from .django import contentstore
# to install PIL on MacOSX: 'easy_install http://dist.repoze.org/PIL-1.1.6.tar.gz'
from PIL import Image
......@@ -61,6 +62,7 @@ class StaticContent(object):
return {'tag': location.tag, 'org': location.org, 'course': location.course,
'category': location.category, 'name': location.name,
'revision': location.revision}
@staticmethod
def get_location_from_path(path):
# remove leading / character if it is there one
......@@ -79,8 +81,6 @@ class StaticContent(object):
return StaticContent.get_url_path_from_location(loc)
class ContentStore(object):
'''
Abstraction for all ContentStore providers (e.g. MongoDB)
......
from __future__ import absolute_import
from importlib import import_module
from os import environ
from django.conf import settings
......
......@@ -6,7 +6,6 @@ from gridfs.errors import NoFile
from xmodule.modulestore.mongo import location_to_query, Location
from xmodule.contentstore.content import XASSET_LOCATION_TAG
import sys
import logging
from .content import StaticContent, ContentStore
......@@ -26,7 +25,6 @@ class MongoContentStore(ContentStore):
self.fs = gridfs.GridFS(_db)
self.fs_files = _db["fs.files"] # the underlying collection GridFS uses
def save(self, content):
id = content.get_id()
......@@ -34,7 +32,8 @@ class MongoContentStore(ContentStore):
self.delete(id)
with self.fs.new_file(_id=id, filename=content.get_url_path(), content_type=content.content_type,
displayname=content.name, thumbnail_location=content.thumbnail_location, import_path=content.import_path) as fp:
displayname=content.name, thumbnail_location=content.thumbnail_location,
import_path=content.import_path) as fp:
fp.write(content.data)
......@@ -49,7 +48,8 @@ class MongoContentStore(ContentStore):
try:
with self.fs.get(id) as fp:
return StaticContent(location, fp.displayname, fp.content_type, fp.read(),
fp.uploadDate, thumbnail_location=fp.thumbnail_location if hasattr(fp, 'thumbnail_location') else None,
fp.uploadDate,
thumbnail_location=fp.thumbnail_location if hasattr(fp, 'thumbnail_location') else None,
import_path=fp.import_path if hasattr(fp, 'import_path') else None)
except NoFile:
raise NotFoundError()
......
......@@ -211,7 +211,6 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
template_dir_name = 'course'
def __init__(self, *args, **kwargs):
super(CourseDescriptor, self).__init__(*args, **kwargs)
......@@ -233,6 +232,7 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
# disable the syllabus content for courses that do not provide a syllabus
self.syllabus_present = self.system.resources_fs.exists(path('syllabus'))
self._grading_policy = {}
self.set_grading_policy(self.grading_policy)
self.test_center_exams = []
......@@ -421,7 +421,6 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
policy['GRADE_CUTOFFS'] = value
self.grading_policy = policy
@property
def lowest_passing_grade(self):
return min(self._grading_policy['GRADE_CUTOFFS'].values())
......@@ -460,7 +459,6 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
else:
return self.cohort_config.get("auto_cohort_groups", [])
@property
def top_level_discussion_topic_ids(self):
"""
......@@ -469,7 +467,6 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
topics = self.discussion_topics
return [d["id"] for d in topics.values()]
@property
def cohorted_discussions(self):
"""
......@@ -483,8 +480,6 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
return set(config.get("cohorted_discussions", []))
@property
def is_newish(self):
"""
......@@ -585,7 +580,6 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
yield module_descriptor
for c in self.get_children():
sections = []
for s in c.get_children():
if s.lms.graded:
xmoduledescriptors = list(yield_descriptor_descendents(s))
......@@ -603,7 +597,6 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
return {'graded_sections': graded_sections,
'all_descriptors': all_descriptors, }
@staticmethod
def make_id(org, course, url_name):
return '/'.join([org, course, url_name])
......
......@@ -122,6 +122,7 @@ div.combined-rubric-container {
span.rubric-category {
font-size: .9em;
font-weight: bold;
}
padding-bottom: 5px;
padding-top: 10px;
......
from lxml import etree
from pkg_resources import resource_string, resource_listdir
from pkg_resources import resource_string
from xmodule.x_module import XModule
from xmodule.raw_module import RawDescriptor
from xmodule.editing_module import MetadataOnlyEditingDescriptor
from xblock.core import String, Scope
......@@ -20,7 +20,6 @@ class DiscussionModule(DiscussionFields, XModule):
}
js_module_name = "InlineDiscussion"
def get_html(self):
context = {
'discussion_id': self.discussion_id,
......@@ -28,7 +27,7 @@ class DiscussionModule(DiscussionFields, XModule):
return self.system.render_template('discussion/_discussion_module.html', context)
class DiscussionDescriptor(DiscussionFields, RawDescriptor):
class DiscussionDescriptor(DiscussionFields, MetadataOnlyEditingDescriptor, RawDescriptor):
module_class = DiscussionModule
template_dir_name = "discussion"
......
......@@ -41,6 +41,18 @@ class XMLEditingDescriptor(EditingDescriptor):
js_module_name = "XMLEditingDescriptor"
class MetadataOnlyEditingDescriptor(EditingDescriptor):
"""
Module which only provides an editing interface for the metadata, it does
not expose a UI for editing the module data
"""
js = {'coffee': [resource_string(__name__, 'js/src/raw/edit/metadata-only.coffee')]}
js_module_name = "MetadataOnlyEditingDescriptor"
mako_template = "widgets/metadata-only-edit.html"
class JSONEditingDescriptor(EditingDescriptor):
"""
Module that provides a raw editing view of its data as XML. It does not perform
......
class InvalidDefinitionError(Exception):
pass
class NotFoundError(Exception):
pass
class ProcessingError(Exception):
'''
An error occurred while processing a request to the XModule.
......
......@@ -51,6 +51,8 @@ class Date(ModelType):
TIMEDELTA_REGEX = re.compile(r'^((?P<days>\d+?) day(?:s?))?(\s)?((?P<hours>\d+?) hour(?:s?))?(\s)?((?P<minutes>\d+?) minute(?:s)?)?(\s)?((?P<seconds>\d+?) second(?:s)?)?$')
class Timedelta(ModelType):
def from_json(self, time_str):
"""
......
......@@ -149,7 +149,6 @@ class FolditModule(FolditFields, XModule):
return 1
class FolditDescriptor(FolditFields, XmlDescriptor, EditingDescriptor):
"""
Module for adding Foldit problems to courses
......
......@@ -118,8 +118,8 @@ class HtmlDescriptor(HtmlFields, XmlDescriptor, EditingDescriptor):
with system.resources_fs.open(filepath) as file:
html = file.read().decode('utf-8')
# Log a warning if we can't parse the file, but don't error
if not check_html(html):
msg = "Couldn't parse html in {0}.".format(filepath)
if not check_html(html) and len(html) > 0:
msg = "Couldn't parse html in {0}, content = {1}".format(filepath, html)
log.warning(msg)
system.error_tracker("Warning: " + msg)
......@@ -156,7 +156,8 @@ class HtmlDescriptor(HtmlFields, XmlDescriptor, EditingDescriptor):
resource_fs.makedir(os.path.dirname(filepath), recursive=True, allow_recreate=True)
with resource_fs.open(filepath, 'w') as file:
file.write(self.data.encode('utf-8'))
html_data = self.data.encode('utf-8')
file.write(html_data)
# write out the relative name
relname = path(pathname).basename()
......
......@@ -90,6 +90,7 @@ class @CombinedOpenEnded
@element=element
@reinitialize(element)
$(window).keydown @keydown_handler
$(window).keyup @keyup_handler
reinitialize: (element) ->
@wrapper=$(element).find('section.xmodule_CombinedOpenEndedModule')
......@@ -104,6 +105,7 @@ class @CombinedOpenEnded
@location = @el.data('location')
# set up handlers for click tracking
Rubric.initialize(@location)
@is_ctrl = false
@allow_reset = @el.data('allow_reset')
@reset_button = @$('.reset-button')
......@@ -322,6 +324,7 @@ class @CombinedOpenEnded
save_answer: (event) =>
event.preventDefault()
max_filesize = 2*1000*1000 #2MB
pre_can_upload_files = @can_upload_files
if @child_state == 'initial'
files = ""
if @can_upload_files == true
......@@ -353,6 +356,7 @@ class @CombinedOpenEnded
@find_assessment_elements()
@rebind()
else
@can_upload_files = pre_can_upload_files
@gentle_alert response.error
$.ajaxWithPrefix("#{@ajax_url}/save_answer",settings)
......@@ -360,10 +364,17 @@ class @CombinedOpenEnded
else
@errors_area.html(@out_of_sync_message)
keydown_handler: (e) =>
# only do anything when the key pressed is the 'enter' key
if e.which == 13 && @child_state == 'assessing' && Rubric.check_complete()
@save_assessment(e)
keydown_handler: (event) =>
#Previously, responses were submitted when hitting enter. Add in a modifier that ensures that ctrl+enter is needed.
if event.which == 17 && @is_ctrl==false
@is_ctrl=true
else if @is_ctrl==true && event.which == 13 && @child_state == 'assessing' && Rubric.check_complete()
@save_assessment(event)
keyup_handler: (event) =>
#Handle keyup event when ctrl key is released
if event.which == 17 && @is_ctrl==true
@is_ctrl=false
save_assessment: (event) =>
event.preventDefault()
......@@ -482,8 +493,10 @@ class @CombinedOpenEnded
if @accept_file_upload == "True"
if window.File and window.FileReader and window.FileList and window.Blob
@can_upload_files = true
@file_upload_area.html('<input type="file" class="file-upload-box">')
@file_upload_area.html('<input type="file" class="file-upload-box"><img class="file-upload-preview" src="#" alt="Uploaded image" />')
@file_upload_area.show()
$('.file-upload-preview').hide()
$('.file-upload-box').change @preview_image
else
@gentle_alert 'File uploads are required for this question, but are not supported in this browser. Try the newest version of google chrome. Alternatively, if you have uploaded the image to the web, you can paste a link to it into the answer box.'
......@@ -539,3 +552,28 @@ class @CombinedOpenEnded
log_feedback_selection: (event) ->
target_selection = $(event.target).val()
Logger.log 'oe_feedback_response_selected', {value: target_selection}
remove_attribute: (name) =>
if $('.file-upload-preview').attr(name)
$('.file-upload-preview')[0].removeAttribute(name)
preview_image: () =>
if $('.file-upload-box')[0].files && $('.file-upload-box')[0].files[0]
reader = new FileReader()
reader.onload = (e) =>
max_dim = 150
@remove_attribute('src')
@remove_attribute('height')
@remove_attribute('width')
$('.file-upload-preview').attr('src', e.target.result)
height_px = $('.file-upload-preview')[0].height
width_px = $('.file-upload-preview')[0].width
scale_factor = 0
if height_px>width_px
scale_factor = height_px/max_dim
else
scale_factor = width_px/max_dim
$('.file-upload-preview')[0].width = width_px/scale_factor
$('.file-upload-preview')[0].height = height_px/scale_factor
$('.file-upload-preview').show()
reader.readAsDataURL($('.file-upload-box')[0].files[0])
......@@ -161,6 +161,7 @@ class @PeerGradingProblem
constructor: (backend) ->
@prompt_wrapper = $('.prompt-wrapper')
@backend = backend
@is_ctrl = false
# get the location of the problem
......@@ -183,6 +184,12 @@ class @PeerGradingProblem
@grading_message.hide()
@question_header = $('.question-header')
@question_header.click @collapse_question
@flag_submission_confirmation = $('.flag-submission-confirmation')
@flag_submission_confirmation_button = $('.flag-submission-confirmation-button')
@flag_submission_removal_button = $('.flag-submission-removal-button')
@flag_submission_confirmation_button.click @close_dialog_box
@flag_submission_removal_button.click @remove_flag
@grading_wrapper =$('.grading-wrapper')
@calibration_feedback_panel = $('.calibration-feedback')
......@@ -212,6 +219,7 @@ class @PeerGradingProblem
@answer_unknown_checkbox = $('.answer-unknown-checkbox')
$(window).keydown @keydown_handler
$(window).keyup @keyup_handler
@collapse_question()
......@@ -233,9 +241,13 @@ class @PeerGradingProblem
@calibration_interstitial_page.hide()
@is_calibrated_check()
@flag_student_checkbox.click =>
@flag_box_checked()
@calibration_feedback_button.hide()
@calibration_feedback_panel.hide()
@error_container.hide()
@flag_submission_confirmation.hide()
@is_calibrated_check()
......@@ -283,6 +295,17 @@ class @PeerGradingProblem
#
##########
remove_flag: () =>
@flag_student_checkbox.removeAttr("checked")
@close_dialog_box()
close_dialog_box: () =>
$( ".flag-submission-confirmation" ).dialog('close')
flag_box_checked: () =>
if @flag_student_checkbox.is(':checked')
$( ".flag-submission-confirmation" ).dialog({ height: 400, width: 400 })
# called after we perform an is_student_calibrated check
calibration_check_callback: (response) =>
if response.success
......@@ -338,13 +361,19 @@ class @PeerGradingProblem
@grade = Rubric.get_total_score()
keydown_handler: (event) =>
if event.which == 13 && @submit_button.is(':visible')
#Previously, responses were submitted when hitting enter. Add in a modifier that ensures that ctrl+enter is needed.
if event.which == 17 && @is_ctrl==false
@is_ctrl=true
else if event.which == 13 && @submit_button.is(':visible') && @is_ctrl==true
if @calibration
@submit_calibration_essay()
else
@submit_grade()
keyup_handler: (event) =>
#Handle keyup event when ctrl key is released
if event.which == 17 && @is_ctrl==true
@is_ctrl=false
##########
......@@ -443,7 +472,6 @@ class @PeerGradingProblem
calibration_wrapper = $('.calibration-feedback-wrapper')
calibration_wrapper.html("<p>The score you gave was: #{@grade}. The actual score is: #{response.actual_score}</p>")
score = parseInt(@grade)
actual_score = parseInt(response.actual_score)
......@@ -452,6 +480,11 @@ class @PeerGradingProblem
else
calibration_wrapper.append("<p>You may want to review the rubric again.</p>")
if response.actual_rubric != undefined
calibration_wrapper.append("<div>Instructor Scored Rubric: #{response.actual_rubric}</div>")
if response.actual_feedback!=undefined
calibration_wrapper.append("<div>Instructor Feedback: #{response.actual_feedback}</div>")
# disable score selection and submission from the grading interface
$("input[name='score-selection']").attr('disabled', true)
@submit_button.hide()
......
class @MetadataOnlyEditingDescriptor extends XModule.Descriptor
constructor: (@element) ->
save: ->
data: null
......@@ -252,7 +252,6 @@ class Location(_LocationBase):
def __repr__(self):
return "Location%s" % repr(tuple(self))
@property
def course_id(self):
"""Return the ID of the Course that this item belongs to by looking
......@@ -414,7 +413,6 @@ class ModuleStore(object):
return courses
class ModuleStoreBase(ModuleStore):
'''
Implement interface functionality that can be shared.
......@@ -425,6 +423,7 @@ class ModuleStoreBase(ModuleStore):
'''
self._location_errors = {} # location -> ErrorLog
self.metadata_inheritance_cache = None
self.modulestore_update_signal = None # can be set by runtime to route notifications of datastore changes
def _get_errorlog(self, location):
"""
......
......@@ -6,7 +6,6 @@ Passes settings.MODULESTORE as kwargs to MongoModuleStore
from __future__ import absolute_import
from importlib import import_module
from os import environ
from django.conf import settings
......
......@@ -3,7 +3,6 @@ from datetime import datetime
from . import ModuleStoreBase, Location, namedtuple_to_son
from .exceptions import ItemNotFoundError
from .inheritance import own_metadata
import logging
DRAFT = 'draft'
......@@ -107,7 +106,7 @@ class DraftModuleStore(ModuleStoreBase):
"""
return wrap_draft(super(DraftModuleStore, self).clone_item(source, as_draft(location)))
def update_item(self, location, data):
def update_item(self, location, data, allow_not_found=False):
"""
Set the data in the item specified by the location to
data
......@@ -116,9 +115,13 @@ class DraftModuleStore(ModuleStoreBase):
data: A nested dictionary of problem data
"""
draft_loc = as_draft(location)
try:
draft_item = self.get_item(location)
if not getattr(draft_item, 'is_draft', False):
self.clone_item(location, draft_loc)
except ItemNotFoundError, e:
if not allow_not_found:
raise e
return super(DraftModuleStore, self).update_item(draft_loc, data)
......@@ -164,7 +167,6 @@ class DraftModuleStore(ModuleStoreBase):
"""
return super(DraftModuleStore, self).delete_item(as_draft(location))
def get_parent_locations(self, location, course_id):
'''Find all locations that are the parents of this location. Needed
for path_to_location().
......@@ -178,6 +180,7 @@ class DraftModuleStore(ModuleStoreBase):
Save a current draft to the underlying modulestore
"""
draft = self.get_item(location)
draft.cms.published_date = datetime.utcnow()
draft.cms.published_by = published_by_id
super(DraftModuleStore, self).update_item(location, draft._model_data._kvs._data)
......
......@@ -12,6 +12,7 @@ INHERITABLE_METADATA = (
'giturl' # for git edit link
)
def compute_inherited_metadata(descriptor):
"""Given a descriptor, traverse all of its descendants and do metadata
inheritance. Should be called on a CourseDescriptor after importing a
......
......@@ -7,8 +7,8 @@ from collections import namedtuple
from fs.osfs import OSFS
from itertools import repeat
from path import path
from datetime import datetime
from operator import attrgetter
from uuid import uuid4
from importlib import import_module
from xmodule.errortracker import null_error_tracker, exc_info_to_str
......@@ -31,6 +31,12 @@ log = logging.getLogger(__name__)
# that assumption will have to change
def get_course_id_no_run(location):
'''
'''
return "/".join([location.org, location.course])
class MongoKeyValueStore(KeyValueStore):
"""
A KeyValueStore that maps keyed data access to one of the 3 data areas
......@@ -125,8 +131,8 @@ class CachingDescriptorSystem(MakoDescriptorSystem):
render_template: a function for rendering templates, as per
MakoDescriptorSystem
"""
super(CachingDescriptorSystem, self).__init__(
self.load_item, resources_fs, error_tracker, render_template)
super(CachingDescriptorSystem, self).__init__(self.load_item, resources_fs,
error_tracker, render_template)
self.modulestore = modulestore
self.module_data = module_data
self.default_class = default_class
......@@ -135,7 +141,6 @@ class CachingDescriptorSystem(MakoDescriptorSystem):
self.course_id = None
self.cached_metadata = cached_metadata
def load_item(self, location):
"""
Return an XModule instance for the specified location
......@@ -198,7 +203,9 @@ def location_to_query(location, wildcard=True):
if wildcard:
for key, value in query.items():
if value is None:
# don't allow wildcards on revision, since public is set as None, so
# its ambiguous between None as a real value versus None=wildcard
if value is None and key != '_id.revision':
del query[key]
return query
......@@ -461,7 +468,7 @@ class MongoModuleStore(ModuleStoreBase):
# if we are loading a course object, if we're not prefetching children (depth != 0) then don't
# bother with the metadata inheritance
return [self._load_item(item, data_cache,
apply_cached_metadata=(item['location']['category']!='course' or depth !=0)) for item in items]
apply_cached_metadata=(item['location']['category'] != 'course' or depth != 0)) for item in items]
def get_courses(self):
'''
......@@ -541,8 +548,15 @@ class MongoModuleStore(ModuleStoreBase):
Clone a new item that is a copy of the item at the location `source`
and writes it to `location`
"""
item = None
try:
source_item = self.collection.find_one(location_to_query(source))
# allow for some programmatically generated substitutions in metadata, e.g. Discussion_id's should be auto-generated
for key in source_item['metadata'].keys():
if source_item['metadata'][key] == '$$GUID$$':
source_item['metadata'][key] = uuid4().hex
source_item['_id'] = Location(location).dict()
self.collection.insert(
source_item,
......@@ -566,12 +580,19 @@ class MongoModuleStore(ModuleStoreBase):
course.tabs = existing_tabs
self.update_metadata(course.location, course._model_data._kvs._metadata)
return item
except pymongo.errors.DuplicateKeyError:
raise DuplicateItemError(location)
# recompute (and update) the metadata inheritance tree which is cached
self.refresh_cached_metadata_inheritance_tree(Location(location))
self.fire_updated_modulestore_signal(get_course_id_no_run(Location(location)), Location(location))
return item
def fire_updated_modulestore_signal(self, course_id, location):
if self.modulestore_update_signal is not None:
self.modulestore_update_signal.send(self, modulestore=self, course_id=course_id,
location=location)
def get_course_for_item(self, location, depth=0):
'''
......@@ -643,6 +664,8 @@ class MongoModuleStore(ModuleStoreBase):
self._update_single_item(location, {'definition.children': children})
# recompute (and update) the metadata inheritance tree which is cached
self.refresh_cached_metadata_inheritance_tree(Location(location))
# fire signal that we've written to DB
self.fire_updated_modulestore_signal(get_course_id_no_run(Location(location)), Location(location))
def update_metadata(self, location, metadata):
"""
......@@ -669,6 +692,7 @@ class MongoModuleStore(ModuleStoreBase):
self._update_single_item(location, {'metadata': metadata})
# recompute (and update) the metadata inheritance tree which is cached
self.refresh_cached_metadata_inheritance_tree(loc)
self.fire_updated_modulestore_signal(get_course_id_no_run(Location(location)), Location(location))
def delete_item(self, location):
"""
......@@ -686,12 +710,12 @@ class MongoModuleStore(ModuleStoreBase):
course.tabs = [tab for tab in existing_tabs if tab.get('url_slug') != location.name]
self.update_metadata(course.location, own_metadata(course))
self.collection.remove({'_id': Location(location).dict()},
# Must include this to avoid the django debug toolbar (which defines the deprecated "safe=False")
# from overriding our default value set in the init method.
safe=self.collection.safe)
self.collection.remove({'_id': Location(location).dict()}, safe=self.collection.safe)
# recompute (and update) the metadata inheritance tree which is cached
self.refresh_cached_metadata_inheritance_tree(Location(location))
self.fire_updated_modulestore_signal(get_course_id_no_run(Location(location)), Location(location))
def get_parent_locations(self, location, course_id):
'''Find all locations that are the parents of this location in this
......
......@@ -3,7 +3,7 @@ from itertools import repeat
from xmodule.course_module import CourseDescriptor
from .exceptions import (ItemNotFoundError, NoPathToItem)
from . import ModuleStore, Location
from . import Location
def path_to_location(modulestore, course_id, location):
......
import logging
from xmodule.contentstore.content import StaticContent
from xmodule.modulestore import Location
from xmodule.modulestore.mongo import MongoModuleStore
......@@ -94,7 +93,7 @@ def clone_course(modulestore, contentstore, source_location, dest_location, dele
return True
def delete_course(modulestore, contentstore, source_location, commit = False):
def delete_course(modulestore, contentstore, source_location, commit=False):
# first check to see if the modulestore is Mongo backed
if not isinstance(modulestore, MongoModuleStore):
raise Exception("Expected a MongoModuleStore in the runtime. Aborting....")
......
import copy
from uuid import uuid4
from django.test import TestCase
from django.conf import settings
import xmodule.modulestore.django
from xmodule.templates import update_templates
class ModuleStoreTestCase(TestCase):
""" Subclass for any test case that uses the mongodb
module store. This populates a uniquely named modulestore
collection with templates before running the TestCase
and drops it they are finished. """
@staticmethod
def flush_mongo_except_templates():
'''
Delete everything in the module store except templates
'''
modulestore = xmodule.modulestore.django.modulestore()
# This query means: every item in the collection
# that is not a template
query = {"_id.course": {"$ne": "templates"}}
# Remove everything except templates
modulestore.collection.remove(query)
@staticmethod
def load_templates_if_necessary():
'''
Load templates into the modulestore only if they do not already exist.
We need the templates, because they are copied to create
XModules such as sections and problems
'''
modulestore = xmodule.modulestore.django.modulestore()
# Count the number of templates
query = {"_id.course": "templates"}
num_templates = modulestore.collection.find(query).count()
if num_templates < 1:
update_templates()
@classmethod
def setUpClass(cls):
'''
Flush the mongo store and set up templates
'''
# Use a uuid to differentiate
# the mongo collections on jenkins.
cls.orig_modulestore = copy.deepcopy(settings.MODULESTORE)
if 'direct' not in settings.MODULESTORE:
settings.MODULESTORE['direct'] = settings.MODULESTORE['default']
settings.MODULESTORE['default']['OPTIONS']['collection'] = 'modulestore_%s' % uuid4().hex
settings.MODULESTORE['direct']['OPTIONS']['collection'] = 'modulestore_%s' % uuid4().hex
xmodule.modulestore.django._MODULESTORES.clear()
print settings.MODULESTORE
TestCase.setUpClass()
@classmethod
def tearDownClass(cls):
'''
Revert to the old modulestore settings
'''
# Clean up by dropping the collection
modulestore = xmodule.modulestore.django.modulestore()
modulestore.collection.drop()
xmodule.modulestore.django._MODULESTORES.clear()
# Restore the original modulestore settings
settings.MODULESTORE = cls.orig_modulestore
def _pre_setup(self):
'''
Remove everything but the templates before each test
'''
# Flush anything that is not a template
ModuleStoreTestCase.flush_mongo_except_templates()
# Check that we have templates loaded; if not, load them
ModuleStoreTestCase.load_templates_if_necessary()
# Call superclass implementation
super(ModuleStoreTestCase, self)._pre_setup()
def _post_teardown(self):
'''
Flush everything we created except the templates
'''
# Flush anything that is not a template
ModuleStoreTestCase.flush_mongo_except_templates()
# Call superclass implementation
super(ModuleStoreTestCase, self)._post_teardown()
This diff is collapsed. Click to expand it.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment