Commit afa7fca1 by Arthur Barrett

Merge branch 'master' into feature/abarrett/annotatable_xmodule

parents e3f12607 0c5ca91c
...@@ -27,4 +27,5 @@ lms/lib/comment_client/python ...@@ -27,4 +27,5 @@ lms/lib/comment_client/python
nosetests.xml nosetests.xml
cover_html/ cover_html/
.idea/ .idea/
.redcar/
chromedriver.log chromedriver.log
\ No newline at end of file
10664: Locked by 10664 at Mon Feb 11 14:22:22 -0500 2013
---
cursor_positions: []
files_to_retain: 0
ce76efcea5f0a5b2238364f81d54f1d393853a1a
\ No newline at end of file
...@@ -5,7 +5,7 @@ from django.test.utils import override_settings ...@@ -5,7 +5,7 @@ from django.test.utils import override_settings
from django.conf import settings from django.conf import settings
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from path import path from path import path
from tempfile import mkdtemp from tempdir import mkdtemp_clean
import json import json
from fs.osfs import OSFS from fs.osfs import OSFS
import copy import copy
...@@ -194,7 +194,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): ...@@ -194,7 +194,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
import_from_xml(ms, 'common/test/data/', ['full']) import_from_xml(ms, 'common/test/data/', ['full'])
location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012') location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012')
root_dir = path(mkdtemp()) root_dir = path(mkdtemp_clean())
print 'Exporting to tempdir = {0}'.format(root_dir) print 'Exporting to tempdir = {0}'.format(root_dir)
...@@ -264,6 +264,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): ...@@ -264,6 +264,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
self.assertContains(resp, '/c4x/edX/full/asset/handouts_schematic_tutorial.pdf') self.assertContains(resp, '/c4x/edX/full/asset/handouts_schematic_tutorial.pdf')
class ContentStoreTest(ModuleStoreTestCase): class ContentStoreTest(ModuleStoreTestCase):
""" """
Tests for the CMS ContentStore application. Tests for the CMS ContentStore application.
...@@ -421,6 +422,64 @@ class ContentStoreTest(ModuleStoreTestCase): ...@@ -421,6 +422,64 @@ class ContentStoreTest(ModuleStoreTestCase):
self.assertIn('markdown', problem.metadata, "markdown is missing from metadata") self.assertIn('markdown', problem.metadata, "markdown is missing from metadata")
self.assertNotIn('markdown', problem.editable_metadata_fields, "Markdown slipped into the editable metadata fields") self.assertNotIn('markdown', problem.editable_metadata_fields, "Markdown slipped into the editable metadata fields")
def test_import_metadata_with_attempts_empty_string(self):
import_from_xml(modulestore(), 'common/test/data/', ['simple'])
ms = modulestore('direct')
did_load_item = False
try:
ms.get_item(Location(['i4x', 'edX', 'simple', 'problem', 'ps01-simple', None]))
did_load_item = True
except ItemNotFoundError:
pass
# make sure we found the item (e.g. it didn't error while loading)
self.assertTrue(did_load_item)
def test_metadata_inheritance(self):
import_from_xml(modulestore(), 'common/test/data/', ['full'])
ms = modulestore('direct')
course = ms.get_item(Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None]))
verticals = ms.get_items(['i4x', 'edX', 'full', 'vertical', None, None])
# let's assert on the metadata_inheritance on an existing vertical
for vertical in verticals:
self.assertIn('xqa_key', vertical.metadata)
self.assertEqual(course.metadata['xqa_key'], vertical.metadata['xqa_key'])
self.assertGreater(len(verticals), 0)
new_component_location = Location('i4x', 'edX', 'full', 'html', 'new_component')
source_template_location = Location('i4x', 'edx', 'templates', 'html', 'Blank_HTML_Page')
# crate a new module and add it as a child to a vertical
ms.clone_item(source_template_location, new_component_location)
parent = verticals[0]
ms.update_children(parent.location, parent.definition.get('children', []) + [new_component_location.url()])
# flush the cache
ms.get_cached_metadata_inheritance_tree(new_component_location, -1)
new_module = ms.get_item(new_component_location)
# check for grace period definition which should be defined at the course level
self.assertIn('graceperiod', new_module.metadata)
self.assertEqual(course.metadata['graceperiod'], new_module.metadata['graceperiod'])
#
# now let's define an override at the leaf node level
#
new_module.metadata['graceperiod'] = '1 day'
ms.update_metadata(new_module.location, new_module.metadata)
# flush the cache and refetch
ms.get_cached_metadata_inheritance_tree(new_component_location, -1)
new_module = ms.get_item(new_component_location)
self.assertIn('graceperiod', new_module.metadata)
self.assertEqual('1 day', new_module.metadata['graceperiod'])
class TemplateTestCase(ModuleStoreTestCase): class TemplateTestCase(ModuleStoreTestCase):
......
...@@ -4,7 +4,6 @@ from django.test.client import Client ...@@ -4,7 +4,6 @@ from django.test.client import Client
from django.conf import settings from django.conf import settings
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from path import path from path import path
from tempfile import mkdtemp
import json import json
from fs.osfs import OSFS from fs.osfs import OSFS
import copy import copy
......
import json import json
import copy import copy
from time import time from uuid import uuid4
from django.test import TestCase from django.test import TestCase
from django.conf import settings from django.conf import settings
...@@ -20,13 +20,12 @@ class ModuleStoreTestCase(TestCase): ...@@ -20,13 +20,12 @@ class ModuleStoreTestCase(TestCase):
def _pre_setup(self): def _pre_setup(self):
super(ModuleStoreTestCase, self)._pre_setup() super(ModuleStoreTestCase, self)._pre_setup()
# Use the current seconds since epoch to differentiate # Use a uuid to differentiate
# the mongo collections on jenkins. # the mongo collections on jenkins.
sec_since_epoch = '%s' % int(time() * 100)
self.orig_MODULESTORE = copy.deepcopy(settings.MODULESTORE) self.orig_MODULESTORE = copy.deepcopy(settings.MODULESTORE)
self.test_MODULESTORE = self.orig_MODULESTORE self.test_MODULESTORE = self.orig_MODULESTORE
self.test_MODULESTORE['default']['OPTIONS']['collection'] = 'modulestore_%s' % sec_since_epoch self.test_MODULESTORE['default']['OPTIONS']['collection'] = 'modulestore_%s' % uuid4().hex
self.test_MODULESTORE['direct']['OPTIONS']['collection'] = 'modulestore_%s' % sec_since_epoch self.test_MODULESTORE['direct']['OPTIONS']['collection'] = 'modulestore_%s' % uuid4().hex
settings.MODULESTORE = self.test_MODULESTORE settings.MODULESTORE = self.test_MODULESTORE
# Flush and initialize the module store # Flush and initialize the module store
......
...@@ -20,7 +20,6 @@ Longer TODO: ...@@ -20,7 +20,6 @@ Longer TODO:
""" """
import sys import sys
import tempfile
import os.path import os.path
import os import os
import lms.envs.common import lms.envs.common
...@@ -59,7 +58,8 @@ sys.path.append(COMMON_ROOT / 'lib') ...@@ -59,7 +58,8 @@ sys.path.append(COMMON_ROOT / 'lib')
############################# WEB CONFIGURATION ############################# ############################# WEB CONFIGURATION #############################
# This is where we stick our compiled template files. # This is where we stick our compiled template files.
MAKO_MODULE_DIR = tempfile.mkdtemp('mako') from tempdir import mkdtemp_clean
MAKO_MODULE_DIR = mkdtemp_clean('mako')
MAKO_TEMPLATES = {} MAKO_TEMPLATES = {}
MAKO_TEMPLATES['main'] = [ MAKO_TEMPLATES['main'] = [
PROJECT_ROOT / 'templates', PROJECT_ROOT / 'templates',
......
...@@ -68,10 +68,10 @@ CMS.Models.Settings.CourseDetails = Backbone.Model.extend({ ...@@ -68,10 +68,10 @@ CMS.Models.Settings.CourseDetails = Backbone.Model.extend({
save_videosource: function(newsource) { save_videosource: function(newsource) {
// newsource either is <video youtube="speed:key, *"/> or just the "speed:key, *" string // newsource either is <video youtube="speed:key, *"/> or just the "speed:key, *" string
// returns the videosource for the preview which iss the key whose speed is closest to 1 // returns the videosource for the preview which iss the key whose speed is closest to 1
if (_.isEmpty(newsource) && !_.isEmpty(this.get('intro_video'))) this.set({'intro_video': null}); if (_.isEmpty(newsource) && !_.isEmpty(this.get('intro_video'))) this.save({'intro_video': null});
// TODO remove all whitespace w/in string // TODO remove all whitespace w/in string
else { else {
if (this.get('intro_video') !== newsource) this.set('intro_video', newsource); if (this.get('intro_video') !== newsource) this.save('intro_video', newsource);
} }
return this.videosourceSample(); return this.videosourceSample();
......
...@@ -498,6 +498,7 @@ input.courseware-unit-search-input { ...@@ -498,6 +498,7 @@ input.courseware-unit-search-input {
} }
&.new-section { &.new-section {
header { header {
height: auto; height: auto;
@include clearfix(); @include clearfix();
...@@ -506,6 +507,15 @@ input.courseware-unit-search-input { ...@@ -506,6 +507,15 @@ input.courseware-unit-search-input {
.expand-collapse-icon { .expand-collapse-icon {
visibility: hidden; visibility: hidden;
} }
.item-details {
padding: 25px 0 0 0;
.section-name {
float: none;
width: 100%;
}
}
} }
} }
......
...@@ -6,6 +6,7 @@ forums, and to the cohort admin views. ...@@ -6,6 +6,7 @@ forums, and to the cohort admin views.
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.http import Http404 from django.http import Http404
import logging import logging
import random
from courseware import courses from courseware import courses
from student.models import get_user_by_username_or_email from student.models import get_user_by_username_or_email
...@@ -64,7 +65,23 @@ def is_commentable_cohorted(course_id, commentable_id): ...@@ -64,7 +65,23 @@ def is_commentable_cohorted(course_id, commentable_id):
ans)) ans))
return ans return ans
def get_cohorted_commentables(course_id):
"""
Given a course_id return a list of strings representing cohorted commentables
"""
course = courses.get_course_by_id(course_id)
if not course.is_cohorted:
# this is the easy case :)
ans = []
else:
ans = course.cohorted_discussions
return ans
def get_cohort(user, course_id): def get_cohort(user, course_id):
""" """
Given a django User and a course_id, return the user's cohort in that Given a django User and a course_id, return the user's cohort in that
...@@ -96,9 +113,30 @@ def get_cohort(user, course_id): ...@@ -96,9 +113,30 @@ def get_cohort(user, course_id):
group_type=CourseUserGroup.COHORT, group_type=CourseUserGroup.COHORT,
users__id=user.id) users__id=user.id)
except CourseUserGroup.DoesNotExist: except CourseUserGroup.DoesNotExist:
# TODO: add auto-cohorting logic here once we know what that will be. # Didn't find the group. We'll go on to create one if needed.
pass
if not course.auto_cohort:
return None return None
choices = course.auto_cohort_groups
if len(choices) == 0:
# Nowhere to put user
log.warning("Course %s is auto-cohorted, but there are no"
" auto_cohort_groups specified",
course_id)
return None
# Put user in a random group, creating it if needed
group_name = random.choice(choices)
group, created = CourseUserGroup.objects.get_or_create(
course_id=course_id,
group_type=CourseUserGroup.COHORT,
name=group_name)
user.course_groups.add(group)
return group
def get_course_cohorts(course_id): def get_course_cohorts(course_id):
""" """
......
...@@ -47,7 +47,10 @@ class TestCohorts(django.test.TestCase): ...@@ -47,7 +47,10 @@ class TestCohorts(django.test.TestCase):
@staticmethod @staticmethod
def config_course_cohorts(course, discussions, def config_course_cohorts(course, discussions,
cohorted, cohorted_discussions=None): cohorted,
cohorted_discussions=None,
auto_cohort=None,
auto_cohort_groups=None):
""" """
Given a course with no discussion set up, add the discussions and set Given a course with no discussion set up, add the discussions and set
the cohort config appropriately. the cohort config appropriately.
...@@ -59,6 +62,9 @@ class TestCohorts(django.test.TestCase): ...@@ -59,6 +62,9 @@ class TestCohorts(django.test.TestCase):
cohorted: bool. cohorted: bool.
cohorted_discussions: optional list of topic names. If specified, cohorted_discussions: optional list of topic names. If specified,
converts them to use the same ids as topic names. converts them to use the same ids as topic names.
auto_cohort: optional bool.
auto_cohort_groups: optional list of strings
(names of groups to put students into).
Returns: Returns:
Nothing -- modifies course in place. Nothing -- modifies course in place.
...@@ -76,6 +82,12 @@ class TestCohorts(django.test.TestCase): ...@@ -76,6 +82,12 @@ class TestCohorts(django.test.TestCase):
if cohorted_discussions is not None: if cohorted_discussions is not None:
d["cohorted_discussions"] = [to_id(name) d["cohorted_discussions"] = [to_id(name)
for name in cohorted_discussions] for name in cohorted_discussions]
if auto_cohort is not None:
d["auto_cohort"] = auto_cohort
if auto_cohort_groups is not None:
d["auto_cohort_groups"] = auto_cohort_groups
course.metadata["cohort_config"] = d course.metadata["cohort_config"] = d
...@@ -89,12 +101,9 @@ class TestCohorts(django.test.TestCase): ...@@ -89,12 +101,9 @@ class TestCohorts(django.test.TestCase):
def test_get_cohort(self): def test_get_cohort(self):
# Need to fix this, but after we're testing on staging. (Looks like """
# problem is that when get_cohort internally tries to look up the Make sure get_cohort() does the right thing when the course is cohorted
# course.id, it fails, even though we loaded it through the modulestore. """
# Proper fix: give all tests a standard modulestore that uses the test
# dir.
course = modulestore().get_course("edX/toy/2012_Fall") course = modulestore().get_course("edX/toy/2012_Fall")
self.assertEqual(course.id, "edX/toy/2012_Fall") self.assertEqual(course.id, "edX/toy/2012_Fall")
self.assertFalse(course.is_cohorted) self.assertFalse(course.is_cohorted)
...@@ -122,6 +131,54 @@ class TestCohorts(django.test.TestCase): ...@@ -122,6 +131,54 @@ class TestCohorts(django.test.TestCase):
self.assertEquals(get_cohort(other_user, course.id), None, self.assertEquals(get_cohort(other_user, course.id), None,
"other_user shouldn't have a cohort") "other_user shouldn't have a cohort")
def test_auto_cohorting(self):
"""
Make sure get_cohort() does the right thing when the course is auto_cohorted
"""
course = modulestore().get_course("edX/toy/2012_Fall")
self.assertEqual(course.id, "edX/toy/2012_Fall")
self.assertFalse(course.is_cohorted)
user1 = User.objects.create(username="test", email="a@b.com")
user2 = User.objects.create(username="test2", email="a2@b.com")
user3 = User.objects.create(username="test3", email="a3@b.com")
cohort = CourseUserGroup.objects.create(name="TestCohort",
course_id=course.id,
group_type=CourseUserGroup.COHORT)
# user1 manually added to a cohort
cohort.users.add(user1)
# Make the course auto cohorted...
self.config_course_cohorts(course, [], cohorted=True,
auto_cohort=True,
auto_cohort_groups=["AutoGroup"])
self.assertEquals(get_cohort(user1, course.id).id, cohort.id,
"user1 should stay put")
self.assertEquals(get_cohort(user2, course.id).name, "AutoGroup",
"user2 should be auto-cohorted")
# Now make the group list empty
self.config_course_cohorts(course, [], cohorted=True,
auto_cohort=True,
auto_cohort_groups=[])
self.assertEquals(get_cohort(user3, course.id), None,
"No groups->no auto-cohorting")
# Now make it different
self.config_course_cohorts(course, [], cohorted=True,
auto_cohort=True,
auto_cohort_groups=["OtherGroup"])
self.assertEquals(get_cohort(user3, course.id).name, "OtherGroup",
"New list->new group")
self.assertEquals(get_cohort(user2, course.id).name, "AutoGroup",
"user2 should still be in originally placed cohort")
def test_get_course_cohorts(self): def test_get_course_cohorts(self):
course1_id = 'a/b/c' course1_id = 'a/b/c'
......
...@@ -9,6 +9,7 @@ from django.template.loaders.app_directories import Loader as AppDirectoriesLoad ...@@ -9,6 +9,7 @@ from django.template.loaders.app_directories import Loader as AppDirectoriesLoad
from mitxmako.template import Template from mitxmako.template import Template
import mitxmako.middleware import mitxmako.middleware
import tempdir
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -30,7 +31,7 @@ class MakoLoader(object): ...@@ -30,7 +31,7 @@ class MakoLoader(object):
if module_directory is None: if module_directory is None:
log.warning("For more caching of mako templates, set the MAKO_MODULE_DIR in settings!") log.warning("For more caching of mako templates, set the MAKO_MODULE_DIR in settings!")
module_directory = tempfile.mkdtemp() module_directory = tempdir.mkdtemp_clean()
self.module_directory = module_directory self.module_directory = module_directory
......
...@@ -13,7 +13,7 @@ ...@@ -13,7 +13,7 @@
# limitations under the License. # limitations under the License.
from mako.lookup import TemplateLookup from mako.lookup import TemplateLookup
import tempfile import tempdir
from django.template import RequestContext from django.template import RequestContext
from django.conf import settings from django.conf import settings
...@@ -29,7 +29,7 @@ class MakoMiddleware(object): ...@@ -29,7 +29,7 @@ class MakoMiddleware(object):
module_directory = getattr(settings, 'MAKO_MODULE_DIR', None) module_directory = getattr(settings, 'MAKO_MODULE_DIR', None)
if module_directory is None: if module_directory is None:
module_directory = tempfile.mkdtemp() module_directory = tempdir.mkdtemp_clean()
for location in template_locations: for location in template_locations:
lookup[location] = TemplateLookup(directories=template_locations[location], lookup[location] = TemplateLookup(directories=template_locations[location],
......
...@@ -7,6 +7,7 @@ import logging ...@@ -7,6 +7,7 @@ import logging
import os import os
from tempfile import mkdtemp from tempfile import mkdtemp
import cStringIO import cStringIO
import shutil
import sys import sys
from django.test import TestCase from django.test import TestCase
...@@ -143,23 +144,18 @@ class PearsonTestCase(TestCase): ...@@ -143,23 +144,18 @@ class PearsonTestCase(TestCase):
''' '''
Base class for tests running Pearson-related commands Base class for tests running Pearson-related commands
''' '''
import_dir = mkdtemp(prefix="import")
export_dir = mkdtemp(prefix="export")
def assertErrorContains(self, error_message, expected): def assertErrorContains(self, error_message, expected):
self.assertTrue(error_message.find(expected) >= 0, 'error message "{}" did not contain "{}"'.format(error_message, expected)) self.assertTrue(error_message.find(expected) >= 0, 'error message "{}" did not contain "{}"'.format(error_message, expected))
def tearDown(self): def setUp(self):
def delete_temp_dir(dirname): self.import_dir = mkdtemp(prefix="import")
if os.path.exists(dirname): self.addCleanup(shutil.rmtree, self.import_dir)
for filename in os.listdir(dirname): self.export_dir = mkdtemp(prefix="export")
os.remove(os.path.join(dirname, filename)) self.addCleanup(shutil.rmtree, self.export_dir)
os.rmdir(dirname)
# clean up after any test data was dumped to temp directory
delete_temp_dir(self.import_dir)
delete_temp_dir(self.export_dir)
def tearDown(self):
pass
# and clean up the database: # and clean up the database:
# TestCenterUser.objects.all().delete() # TestCenterUser.objects.all().delete()
# TestCenterRegistration.objects.all().delete() # TestCenterRegistration.objects.all().delete()
......
...@@ -111,7 +111,7 @@ class DragAndDrop(object): ...@@ -111,7 +111,7 @@ class DragAndDrop(object):
Returns: bool. Returns: bool.
''' '''
for draggable in self.excess_draggables: for draggable in self.excess_draggables:
if not self.excess_draggables[draggable]: if self.excess_draggables[draggable]:
return False # user answer has more draggables than correct answer return False # user answer has more draggables than correct answer
# Number of draggables in user_groups may be differ that in # Number of draggables in user_groups may be differ that in
...@@ -304,8 +304,13 @@ class DragAndDrop(object): ...@@ -304,8 +304,13 @@ class DragAndDrop(object):
user_answer = json.loads(user_answer) user_answer = json.loads(user_answer)
# check if we have draggables that are not in correct answer: # This dictionary will hold a key for each draggable the user placed on
self.excess_draggables = {} # the image. The value is True if that draggable is not mentioned in any
# correct_answer entries. If the draggable is mentioned in at least one
# correct_answer entry, the value is False.
# default to consider every user answer excess until proven otherwise.
self.excess_draggables = dict((users_draggable.keys()[0],True)
for users_draggable in user_answer['draggables'])
# create identical data structures from user answer and correct answer # create identical data structures from user answer and correct answer
for i in xrange(0, len(correct_answer)): for i in xrange(0, len(correct_answer)):
...@@ -322,11 +327,8 @@ class DragAndDrop(object): ...@@ -322,11 +327,8 @@ class DragAndDrop(object):
self.user_groups[groupname].append(draggable_name) self.user_groups[groupname].append(draggable_name)
self.user_positions[groupname]['user'].append( self.user_positions[groupname]['user'].append(
draggable_dict[draggable_name]) draggable_dict[draggable_name])
self.excess_draggables[draggable_name] = True # proved that this is not excess
else: self.excess_draggables[draggable_name] = False
self.excess_draggables[draggable_name] = \
self.excess_draggables.get(draggable_name, False)
def grade(user_input, correct_answer): def grade(user_input, correct_answer):
""" Creates DragAndDrop instance from user_input and correct_answer and """ Creates DragAndDrop instance from user_input and correct_answer and
......
...@@ -46,6 +46,18 @@ class Test_DragAndDrop_Grade(unittest.TestCase): ...@@ -46,6 +46,18 @@ class Test_DragAndDrop_Grade(unittest.TestCase):
correct_answer = {'1': 't1', 'name_with_icon': 't2'} correct_answer = {'1': 't1', 'name_with_icon': 't2'}
self.assertTrue(draganddrop.grade(user_input, correct_answer)) self.assertTrue(draganddrop.grade(user_input, correct_answer))
def test_expect_no_actions_wrong(self):
user_input = '{"draggables": [{"1": "t1"}, \
{"name_with_icon": "t2"}]}'
correct_answer = []
self.assertFalse(draganddrop.grade(user_input, correct_answer))
def test_expect_no_actions_right(self):
user_input = '{"draggables": []}'
correct_answer = []
self.assertTrue(draganddrop.grade(user_input, correct_answer))
def test_targets_false(self): def test_targets_false(self):
user_input = '{"draggables": [{"1": "t1"}, \ user_input = '{"draggables": [{"1": "t1"}, \
{"name_with_icon": "t2"}]}' {"name_with_icon": "t2"}]}'
......
"""Make temporary directories nicely."""
import atexit
import os.path
import shutil
import tempfile
def mkdtemp_clean(suffix="", prefix="tmp", dir=None):
"""Just like mkdtemp, but the directory will be deleted when the process ends."""
the_dir = tempfile.mkdtemp(suffix=suffix, prefix=prefix, dir=dir)
atexit.register(cleanup_tempdir, the_dir)
return the_dir
def cleanup_tempdir(the_dir):
"""Called on process exit to remove a temp directory."""
if os.path.exists(the_dir):
shutil.rmtree(the_dir)
...@@ -429,6 +429,11 @@ class CapaModule(XModule): ...@@ -429,6 +429,11 @@ class CapaModule(XModule):
# used by conditional module # used by conditional module
return self.attempts > 0 return self.attempts > 0
def is_correct(self):
"""True if full points"""
d = self.get_score()
return d['score'] == d['total']
def answer_available(self): def answer_available(self):
''' '''
Is the user allowed to see an answer? Is the user allowed to see an answer?
...@@ -449,6 +454,9 @@ class CapaModule(XModule): ...@@ -449,6 +454,9 @@ class CapaModule(XModule):
return self.lcp.done return self.lcp.done
elif self.show_answer == 'closed': elif self.show_answer == 'closed':
return self.closed() return self.closed()
elif self.show_answer == 'finished':
return self.closed() or self.is_correct()
elif self.show_answer == 'past_due': elif self.show_answer == 'past_due':
return self.is_past_due() return self.is_past_due()
elif self.show_answer == 'always': elif self.show_answer == 'always':
......
...@@ -108,11 +108,13 @@ class CombinedOpenEndedModule(XModule): ...@@ -108,11 +108,13 @@ class CombinedOpenEndedModule(XModule):
instance_state = {} instance_state = {}
self.version = self.metadata.get('version', DEFAULT_VERSION) self.version = self.metadata.get('version', DEFAULT_VERSION)
version_error_string = "Version of combined open ended module {0} is not correct. Going with version {1}"
if not isinstance(self.version, basestring): if not isinstance(self.version, basestring):
try: try:
self.version = str(self.version) self.version = str(self.version)
except: except:
log.error("Version {0} is not correct. Going with version {1}".format(self.version, DEFAULT_VERSION)) #This is a dev_facing_error
log.info(version_error_string.format(self.version, DEFAULT_VERSION))
self.version = DEFAULT_VERSION self.version = DEFAULT_VERSION
versions = [i[0] for i in VERSION_TUPLES] versions = [i[0] for i in VERSION_TUPLES]
...@@ -122,7 +124,8 @@ class CombinedOpenEndedModule(XModule): ...@@ -122,7 +124,8 @@ class CombinedOpenEndedModule(XModule):
try: try:
version_index = versions.index(self.version) version_index = versions.index(self.version)
except: except:
log.error("Version {0} is not correct. Going with version {1}".format(self.version, DEFAULT_VERSION)) #This is a dev_facing_error
log.error(version_error_string.format(self.version, DEFAULT_VERSION))
self.version = DEFAULT_VERSION self.version = DEFAULT_VERSION
version_index = versions.index(self.version) version_index = versions.index(self.version)
...@@ -205,4 +208,4 @@ class CombinedOpenEndedDescriptor(XmlDescriptor, EditingDescriptor): ...@@ -205,4 +208,4 @@ class CombinedOpenEndedDescriptor(XmlDescriptor, EditingDescriptor):
for child in ['task']: for child in ['task']:
add_child(child) add_child(child)
return elt return elt
\ No newline at end of file
...@@ -352,6 +352,13 @@ class CourseDescriptor(SequenceDescriptor): ...@@ -352,6 +352,13 @@ class CourseDescriptor(SequenceDescriptor):
""" """
return self.metadata.get('tabs') return self.metadata.get('tabs')
@property
def pdf_textbooks(self):
"""
Return the pdf_textbooks config, as a python object, or None if not specified.
"""
return self.metadata.get('pdf_textbooks')
@tabs.setter @tabs.setter
def tabs(self, value): def tabs(self, value):
self.metadata['tabs'] = value self.metadata['tabs'] = value
...@@ -372,6 +379,28 @@ class CourseDescriptor(SequenceDescriptor): ...@@ -372,6 +379,28 @@ class CourseDescriptor(SequenceDescriptor):
return bool(config.get("cohorted")) return bool(config.get("cohorted"))
@property @property
def auto_cohort(self):
"""
Return whether the course is auto-cohorted.
"""
if not self.is_cohorted:
return False
return bool(self.metadata.get("cohort_config", {}).get(
"auto_cohort", False))
@property
def auto_cohort_groups(self):
"""
Return the list of groups to put students into. Returns [] if not
specified. Returns specified list even if is_cohorted and/or auto_cohort are
false.
"""
return self.metadata.get("cohort_config", {}).get(
"auto_cohort_groups", [])
@property
def top_level_discussion_topic_ids(self): def top_level_discussion_topic_ids(self):
""" """
Return list of topic ids defined in course policy. Return list of topic ids defined in course policy.
...@@ -707,7 +736,7 @@ class CourseDescriptor(SequenceDescriptor): ...@@ -707,7 +736,7 @@ class CourseDescriptor(SequenceDescriptor):
def get_test_center_exam(self, exam_series_code): def get_test_center_exam(self, exam_series_code):
exams = [exam for exam in self.test_center_exams if exam.exam_series_code == exam_series_code] exams = [exam for exam in self.test_center_exams if exam.exam_series_code == exam_series_code]
return exams[0] if len(exams) == 1 else None return exams[0] if len(exams) == 1 else None
@property @property
def title(self): def title(self):
return self.display_name return self.display_name
......
class @Rubric class @Rubric
constructor: () -> constructor: () ->
@initialize: (location) ->
$('.rubric').data("location", location)
$('input[class="score-selection"]').change @tracking_callback
# set up the hotkeys
$(window).unbind('keydown', @keypress_callback)
$(window).keydown @keypress_callback
# display the 'current' carat
@categories = $('.rubric-category')
@category = $(@categories.first())
@category.prepend('> ')
@category_index = 0
@keypress_callback: (event) =>
# don't try to do this when user is typing in a text input
if $(event.target).is('input, textarea')
return
# for when we select via top row
if event.which >= 48 and event.which <= 57
selected = event.which - 48
# for when we select via numpad
else if event.which >= 96 and event.which <= 105
selected = event.which - 96
# we don't want to do anything since we haven't pressed a number
else
return
# if we actually have a current category (not past the end)
if(@category_index <= @categories.length)
# find the valid selections for this category
inputs = $("input[name='score-selection-#{@category_index}']")
max_score = inputs.length - 1
if selected > max_score or selected < 0
return
inputs.filter("input[value=#{selected}]").click()
# move to the next category
old_category_text = @category.html().substring(5)
@category.html(old_category_text)
@category_index++
@category = $(@categories[@category_index])
@category.prepend('> ')
@tracking_callback: (event) ->
target_selection = $(event.target).val()
# chop off the beginning of the name so that we can get the number of the category
category = $(event.target).data("category")
location = $('.rubric').data('location')
# probably want the original problem location as well
data = {location: location, selection: target_selection, category: category}
Logger.log 'rubric_select', data
# finds the scores for each rubric category # finds the scores for each rubric category
@get_score_list: () => @get_score_list: () =>
# find the number of categories: # find the number of categories:
...@@ -34,6 +89,7 @@ class @CombinedOpenEnded ...@@ -34,6 +89,7 @@ class @CombinedOpenEnded
constructor: (element) -> constructor: (element) ->
@element=element @element=element
@reinitialize(element) @reinitialize(element)
$(window).keydown @keydown_handler
reinitialize: (element) -> reinitialize: (element) ->
@wrapper=$(element).find('section.xmodule_CombinedOpenEndedModule') @wrapper=$(element).find('section.xmodule_CombinedOpenEndedModule')
...@@ -45,6 +101,9 @@ class @CombinedOpenEnded ...@@ -45,6 +101,9 @@ class @CombinedOpenEnded
@task_count = @el.data('task-count') @task_count = @el.data('task-count')
@task_number = @el.data('task-number') @task_number = @el.data('task-number')
@accept_file_upload = @el.data('accept-file-upload') @accept_file_upload = @el.data('accept-file-upload')
@location = @el.data('location')
# set up handlers for click tracking
Rubric.initialize(@location)
@allow_reset = @el.data('allow_reset') @allow_reset = @el.data('allow_reset')
@reset_button = @$('.reset-button') @reset_button = @$('.reset-button')
...@@ -89,6 +148,8 @@ class @CombinedOpenEnded ...@@ -89,6 +148,8 @@ class @CombinedOpenEnded
@can_upload_files = false @can_upload_files = false
@open_ended_child= @$('.open-ended-child') @open_ended_child= @$('.open-ended-child')
@out_of_sync_message = 'The problem state got out of sync. Try reloading the page.'
if @task_number>1 if @task_number>1
@prompt_hide() @prompt_hide()
else if @task_number==1 and @child_state!='initial' else if @task_number==1 and @child_state!='initial'
...@@ -116,6 +177,9 @@ class @CombinedOpenEnded ...@@ -116,6 +177,9 @@ class @CombinedOpenEnded
@submit_evaluation_button = $('.submit-evaluation-button') @submit_evaluation_button = $('.submit-evaluation-button')
@submit_evaluation_button.click @message_post @submit_evaluation_button.click @message_post
Collapsible.setCollapsibles(@results_container) Collapsible.setCollapsibles(@results_container)
# make sure we still have click tracking
$('.evaluation-response a').click @log_feedback_click
$('input[name="evaluation-score"]').change @log_feedback_selection
show_results: (event) => show_results: (event) =>
status_item = $(event.target).parent() status_item = $(event.target).parent()
...@@ -153,7 +217,6 @@ class @CombinedOpenEnded ...@@ -153,7 +217,6 @@ class @CombinedOpenEnded
@legend_container= $('.legend-container') @legend_container= $('.legend-container')
message_post: (event)=> message_post: (event)=>
Logger.log 'message_post', @answers
external_grader_message=$(event.target).parent().parent().parent() external_grader_message=$(event.target).parent().parent().parent()
evaluation_scoring = $(event.target).parent() evaluation_scoring = $(event.target).parent()
...@@ -182,6 +245,7 @@ class @CombinedOpenEnded ...@@ -182,6 +245,7 @@ class @CombinedOpenEnded
$('section.evaluation').slideToggle() $('section.evaluation').slideToggle()
@message_wrapper.html(response.message_html) @message_wrapper.html(response.message_html)
$.ajaxWithPrefix("#{@ajax_url}/save_post_assessment", settings) $.ajaxWithPrefix("#{@ajax_url}/save_post_assessment", settings)
...@@ -283,6 +347,7 @@ class @CombinedOpenEnded ...@@ -283,6 +347,7 @@ class @CombinedOpenEnded
if response.success if response.success
@rubric_wrapper.html(response.rubric_html) @rubric_wrapper.html(response.rubric_html)
@rubric_wrapper.show() @rubric_wrapper.show()
Rubric.initialize(@location)
@answer_area.html(response.student_response) @answer_area.html(response.student_response)
@child_state = 'assessing' @child_state = 'assessing'
@find_assessment_elements() @find_assessment_elements()
...@@ -293,7 +358,12 @@ class @CombinedOpenEnded ...@@ -293,7 +358,12 @@ class @CombinedOpenEnded
$.ajaxWithPrefix("#{@ajax_url}/save_answer",settings) $.ajaxWithPrefix("#{@ajax_url}/save_answer",settings)
else else
@errors_area.html('Problem state got out of sync. Try reloading the page.') @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)
save_assessment: (event) => save_assessment: (event) =>
event.preventDefault() event.preventDefault()
...@@ -315,7 +385,7 @@ class @CombinedOpenEnded ...@@ -315,7 +385,7 @@ class @CombinedOpenEnded
else else
@errors_area.html(response.error) @errors_area.html(response.error)
else else
@errors_area.html('Problem state got out of sync. Try reloading the page.') @errors_area.html(@out_of_sync_message)
save_hint: (event) => save_hint: (event) =>
event.preventDefault() event.preventDefault()
...@@ -330,7 +400,7 @@ class @CombinedOpenEnded ...@@ -330,7 +400,7 @@ class @CombinedOpenEnded
else else
@errors_area.html(response.error) @errors_area.html(response.error)
else else
@errors_area.html('Problem state got out of sync. Try reloading the page.') @errors_area.html(@out_of_sync_message)
skip_post_assessment: => skip_post_assessment: =>
if @child_state == 'post_assessment' if @child_state == 'post_assessment'
...@@ -342,7 +412,7 @@ class @CombinedOpenEnded ...@@ -342,7 +412,7 @@ class @CombinedOpenEnded
else else
@errors_area.html(response.error) @errors_area.html(response.error)
else else
@errors_area.html('Problem state got out of sync. Try reloading the page.') @errors_area.html(@out_of_sync_message)
reset: (event) => reset: (event) =>
event.preventDefault() event.preventDefault()
...@@ -362,7 +432,7 @@ class @CombinedOpenEnded ...@@ -362,7 +432,7 @@ class @CombinedOpenEnded
else else
@errors_area.html(response.error) @errors_area.html(response.error)
else else
@errors_area.html('Problem state got out of sync. Try reloading the page.') @errors_area.html(@out_of_sync_message)
next_problem: => next_problem: =>
if @child_state == 'done' if @child_state == 'done'
...@@ -385,7 +455,7 @@ class @CombinedOpenEnded ...@@ -385,7 +455,7 @@ class @CombinedOpenEnded
else else
@errors_area.html(response.error) @errors_area.html(response.error)
else else
@errors_area.html('Problem state got out of sync. Try reloading the page.') @errors_area.html(@out_of_sync_message)
gentle_alert: (msg) => gentle_alert: (msg) =>
if @el.find('.open-ended-alert').length if @el.find('.open-ended-alert').length
...@@ -404,7 +474,7 @@ class @CombinedOpenEnded ...@@ -404,7 +474,7 @@ class @CombinedOpenEnded
$.postWithPrefix "#{@ajax_url}/check_for_score", (response) => $.postWithPrefix "#{@ajax_url}/check_for_score", (response) =>
if response.state == "done" or response.state=="post_assessment" if response.state == "done" or response.state=="post_assessment"
delete window.queuePollerID delete window.queuePollerID
location.reload() @reload()
else else
window.queuePollerID = window.setTimeout(@poll, 10000) window.queuePollerID = window.setTimeout(@poll, 10000)
...@@ -438,7 +508,9 @@ class @CombinedOpenEnded ...@@ -438,7 +508,9 @@ class @CombinedOpenEnded
@prompt_container.toggleClass('open') @prompt_container.toggleClass('open')
if @question_header.text() == "(Hide)" if @question_header.text() == "(Hide)"
new_text = "(Show)" new_text = "(Show)"
Logger.log 'oe_hide_question', {location: @location}
else else
Logger.log 'oe_show_question', {location: @location}
new_text = "(Hide)" new_text = "(Hide)"
@question_header.text(new_text) @question_header.text(new_text)
...@@ -454,4 +526,16 @@ class @CombinedOpenEnded ...@@ -454,4 +526,16 @@ class @CombinedOpenEnded
@prompt_container.toggleClass('open') @prompt_container.toggleClass('open')
@question_header.text("(Show)") @question_header.text("(Show)")
log_feedback_click: (event) ->
link_text = $(event.target).html()
if link_text == 'See full feedback'
Logger.log 'oe_show_full_feedback', {}
else if link_text == 'Respond to Feedback'
Logger.log 'oe_show_respond_to_feedback', {}
else
generated_event_type = link_text.toLowerCase().replace(" ","_")
Logger.log "oe_" + generated_event_type, {}
log_feedback_selection: (event) ->
target_selection = $(event.target).val()
Logger.log 'oe_feedback_response_selected', {value: target_selection}
...@@ -175,6 +175,7 @@ class @PeerGradingProblem ...@@ -175,6 +175,7 @@ class @PeerGradingProblem
@prompt_container = $('.prompt-container') @prompt_container = $('.prompt-container')
@rubric_container = $('.rubric-container') @rubric_container = $('.rubric-container')
@flag_student_container = $('.flag-student-container') @flag_student_container = $('.flag-student-container')
@answer_unknown_container = $('.answer-unknown-container')
@calibration_panel = $('.calibration-panel') @calibration_panel = $('.calibration-panel')
@grading_panel = $('.grading-panel') @grading_panel = $('.grading-panel')
@content_panel = $('.content-panel') @content_panel = $('.content-panel')
...@@ -208,6 +209,10 @@ class @PeerGradingProblem ...@@ -208,6 +209,10 @@ class @PeerGradingProblem
@interstitial_page_button = $('.interstitial-page-button') @interstitial_page_button = $('.interstitial-page-button')
@calibration_interstitial_page_button = $('.calibration-interstitial-page-button') @calibration_interstitial_page_button = $('.calibration-interstitial-page-button')
@flag_student_checkbox = $('.flag-checkbox') @flag_student_checkbox = $('.flag-checkbox')
@answer_unknown_checkbox = $('.answer-unknown-checkbox')
$(window).keydown @keydown_handler
@collapse_question() @collapse_question()
Collapsible.setCollapsibles(@content_panel) Collapsible.setCollapsibles(@content_panel)
...@@ -249,9 +254,6 @@ class @PeerGradingProblem ...@@ -249,9 +254,6 @@ class @PeerGradingProblem
fetch_submission_essay: () => fetch_submission_essay: () =>
@backend.post('get_next_submission', {location: @location}, @render_submission) @backend.post('get_next_submission', {location: @location}, @render_submission)
gentle_alert: (msg) =>
@grading_message.fadeIn()
@grading_message.html("<p>" + msg + "</p>")
construct_data: () -> construct_data: () ->
data = data =
...@@ -262,6 +264,7 @@ class @PeerGradingProblem ...@@ -262,6 +264,7 @@ class @PeerGradingProblem
submission_key: @submission_key_input.val() submission_key: @submission_key_input.val()
feedback: @feedback_area.val() feedback: @feedback_area.val()
submission_flagged: @flag_student_checkbox.is(':checked') submission_flagged: @flag_student_checkbox.is(':checked')
answer_unknown: @answer_unknown_checkbox.is(':checked')
return data return data
...@@ -334,6 +337,14 @@ class @PeerGradingProblem ...@@ -334,6 +337,14 @@ class @PeerGradingProblem
@show_submit_button() @show_submit_button()
@grade = Rubric.get_total_score() @grade = Rubric.get_total_score()
keydown_handler: (event) =>
if event.which == 13 && @submit_button.is(':visible')
if @calibration
@submit_calibration_essay()
else
@submit_grade()
########## ##########
...@@ -360,6 +371,8 @@ class @PeerGradingProblem ...@@ -360,6 +371,8 @@ class @PeerGradingProblem
@calibration_panel.find('.grading-text').hide() @calibration_panel.find('.grading-text').hide()
@grading_panel.find('.grading-text').hide() @grading_panel.find('.grading-text').hide()
@flag_student_container.hide() @flag_student_container.hide()
@answer_unknown_container.hide()
@feedback_area.val("") @feedback_area.val("")
@submit_button.unbind('click') @submit_button.unbind('click')
...@@ -388,6 +401,7 @@ class @PeerGradingProblem ...@@ -388,6 +401,7 @@ class @PeerGradingProblem
@calibration_panel.find('.grading-text').show() @calibration_panel.find('.grading-text').show()
@grading_panel.find('.grading-text').show() @grading_panel.find('.grading-text').show()
@flag_student_container.show() @flag_student_container.show()
@answer_unknown_container.show()
@feedback_area.val("") @feedback_area.val("")
@submit_button.unbind('click') @submit_button.unbind('click')
...@@ -420,6 +434,7 @@ class @PeerGradingProblem ...@@ -420,6 +434,7 @@ class @PeerGradingProblem
@submit_button.hide() @submit_button.hide()
@action_button.hide() @action_button.hide()
@calibration_feedback_panel.hide() @calibration_feedback_panel.hide()
Rubric.initialize(@location)
render_calibration_feedback: (response) => render_calibration_feedback: (response) =>
...@@ -466,11 +481,17 @@ class @PeerGradingProblem ...@@ -466,11 +481,17 @@ class @PeerGradingProblem
# And now hook up an event handler again # And now hook up an event handler again
$("input[class='score-selection']").change @graded_callback $("input[class='score-selection']").change @graded_callback
gentle_alert: (msg) =>
@grading_message.fadeIn()
@grading_message.html("<p>" + msg + "</p>")
collapse_question: () => collapse_question: () =>
@prompt_container.slideToggle() @prompt_container.slideToggle()
@prompt_container.toggleClass('open') @prompt_container.toggleClass('open')
if @question_header.text() == "(Hide)" if @question_header.text() == "(Hide)"
Logger.log 'peer_grading_hide_question', {location: @location}
new_text = "(Show)" new_text = "(Show)"
else else
Logger.log 'peer_grading_show_question', {location: @location}
new_text = "(Hide)" new_text = "(Hide)"
@question_header.text(new_text) @question_header.text(new_text)
...@@ -44,5 +44,6 @@ class MakoModuleDescriptor(XModuleDescriptor): ...@@ -44,5 +44,6 @@ class MakoModuleDescriptor(XModuleDescriptor):
# cdodge: encapsulate a means to expose "editable" metadata fields (i.e. not internal system metadata) # cdodge: encapsulate a means to expose "editable" metadata fields (i.e. not internal system metadata)
@property @property
def editable_metadata_fields(self): def editable_metadata_fields(self):
subset = [name for name in self.metadata.keys() if name not in self.system_metadata_fields] subset = [name for name in self.metadata.keys() if name not in self.system_metadata_fields and
name not in self._inherited_metadata]
return subset return subset
...@@ -23,6 +23,15 @@ URL_RE = re.compile(""" ...@@ -23,6 +23,15 @@ URL_RE = re.compile("""
(@(?P<revision>[^/]+))? (@(?P<revision>[^/]+))?
""", re.VERBOSE) """, re.VERBOSE)
MISSING_SLASH_URL_RE = re.compile("""
(?P<tag>[^:]+):/
(?P<org>[^/]+)/
(?P<course>[^/]+)/
(?P<category>[^/]+)/
(?P<name>[^@]+)
(@(?P<revision>[^/]+))?
""", re.VERBOSE)
# TODO (cpennington): We should decide whether we want to expand the # TODO (cpennington): We should decide whether we want to expand the
# list of valid characters in a location # list of valid characters in a location
INVALID_CHARS = re.compile(r"[^\w.-]") INVALID_CHARS = re.compile(r"[^\w.-]")
...@@ -164,12 +173,16 @@ class Location(_LocationBase): ...@@ -164,12 +173,16 @@ class Location(_LocationBase):
if isinstance(location, basestring): if isinstance(location, basestring):
match = URL_RE.match(location) match = URL_RE.match(location)
if match is None: if match is None:
log.debug('location is instance of %s but no URL match' % basestring) # cdodge:
raise InvalidLocationError(location) # check for a dropped slash near the i4x:// element of the location string. This can happen with some
else: # redirects (e.g. edx.org -> www.edx.org which I think happens in Nginx)
groups = match.groupdict() match = MISSING_SLASH_URL_RE.match(location)
check_dict(groups) if match is None:
return _LocationBase.__new__(_cls, **groups) log.debug('location is instance of %s but no URL match' % basestring)
raise InvalidLocationError(location)
groups = match.groupdict()
check_dict(groups)
return _LocationBase.__new__(_cls, **groups)
elif isinstance(location, (list, tuple)): elif isinstance(location, (list, tuple)):
if len(location) not in (5, 6): if len(location) not in (5, 6):
log.debug('location has wrong length') log.debug('location has wrong length')
......
import pymongo import pymongo
import sys import sys
import logging import logging
import copy
from bson.son import SON from bson.son import SON
from fs.osfs import OSFS from fs.osfs import OSFS
from itertools import repeat from itertools import repeat
from path import path from path import path
from datetime import datetime, timedelta
from importlib import import_module from importlib import import_module
from xmodule.errortracker import null_error_tracker, exc_info_to_str from xmodule.errortracker import null_error_tracker, exc_info_to_str
...@@ -27,9 +29,11 @@ class CachingDescriptorSystem(MakoDescriptorSystem): ...@@ -27,9 +29,11 @@ class CachingDescriptorSystem(MakoDescriptorSystem):
""" """
A system that has a cache of module json that it will use to load modules A system that has a cache of module json that it will use to load modules
from, with a backup of calling to the underlying modulestore for more data from, with a backup of calling to the underlying modulestore for more data
TODO (cdodge) when the 'split module store' work has been completed we can remove all
references to metadata_inheritance_tree
""" """
def __init__(self, modulestore, module_data, default_class, resources_fs, def __init__(self, modulestore, module_data, default_class, resources_fs,
error_tracker, render_template): error_tracker, render_template, metadata_inheritance_tree = None):
""" """
modulestore: the module store that can be used to retrieve additional modules modulestore: the module store that can be used to retrieve additional modules
...@@ -54,6 +58,7 @@ class CachingDescriptorSystem(MakoDescriptorSystem): ...@@ -54,6 +58,7 @@ class CachingDescriptorSystem(MakoDescriptorSystem):
# cdodge: other Systems have a course_id attribute defined. To keep things consistent, let's # cdodge: other Systems have a course_id attribute defined. To keep things consistent, let's
# define an attribute here as well, even though it's None # define an attribute here as well, even though it's None
self.course_id = None self.course_id = None
self.metadata_inheritance_tree = metadata_inheritance_tree
def load_item(self, location): def load_item(self, location):
location = Location(location) location = Location(location)
...@@ -61,11 +66,13 @@ class CachingDescriptorSystem(MakoDescriptorSystem): ...@@ -61,11 +66,13 @@ class CachingDescriptorSystem(MakoDescriptorSystem):
if json_data is None: if json_data is None:
return self.modulestore.get_item(location) return self.modulestore.get_item(location)
else: else:
# TODO (vshnayder): metadata inheritance is somewhat broken because mongo, doesn't # load the module and apply the inherited metadata
# always load an entire course. We're punting on this until after launch, and then
# will build a proper course policy framework.
try: try:
return XModuleDescriptor.load_from_json(json_data, self, self.default_class) module = XModuleDescriptor.load_from_json(json_data, self, self.default_class)
if self.metadata_inheritance_tree is not None:
metadata_to_inherit = self.metadata_inheritance_tree.get('parent_metadata', {}).get(location.url(),{})
module.inherit_metadata(metadata_to_inherit)
return module
except: except:
return ErrorDescriptor.from_json( return ErrorDescriptor.from_json(
json_data, json_data,
...@@ -142,6 +149,82 @@ class MongoModuleStore(ModuleStoreBase): ...@@ -142,6 +149,82 @@ class MongoModuleStore(ModuleStoreBase):
self.fs_root = path(fs_root) self.fs_root = path(fs_root)
self.error_tracker = error_tracker self.error_tracker = error_tracker
self.render_template = render_template self.render_template = render_template
self.metadata_inheritance_cache = {}
def get_metadata_inheritance_tree(self, location):
'''
TODO (cdodge) This method can be deleted when the 'split module store' work has been completed
'''
# get all collections in the course, this query should not return any leaf nodes
query = { '_id.org' : location.org,
'_id.course' : location.course,
'_id.revision' : None,
'definition.children':{'$ne': []}
}
# we just want the Location, children, and metadata
record_filter = {'_id':1,'definition.children':1,'metadata':1}
# call out to the DB
resultset = self.collection.find(query, record_filter)
results_by_url = {}
root = None
# now go through the results and order them by the location url
for result in resultset:
location = Location(result['_id'])
results_by_url[location.url()] = result
if location.category == 'course':
root = location.url()
# now traverse the tree and compute down the inherited metadata
metadata_to_inherit = {}
def _compute_inherited_metadata(url):
my_metadata = results_by_url[url]['metadata']
for key in my_metadata.keys():
if key not in XModuleDescriptor.inheritable_metadata:
del my_metadata[key]
results_by_url[url]['metadata'] = my_metadata
# go through all the children and recurse, but only if we have
# in the result set. Remember results will not contain leaf nodes
for child in results_by_url[url].get('definition',{}).get('children',[]):
if child in results_by_url:
new_child_metadata = copy.deepcopy(my_metadata)
new_child_metadata.update(results_by_url[child]['metadata'])
results_by_url[child]['metadata'] = new_child_metadata
metadata_to_inherit[child] = new_child_metadata
_compute_inherited_metadata(child)
else:
# this is likely a leaf node, so let's record what metadata we need to inherit
metadata_to_inherit[child] = my_metadata
if root is not None:
_compute_inherited_metadata(root)
cache = {'parent_metadata': metadata_to_inherit,
'timestamp' : datetime.now()}
return cache
def get_cached_metadata_inheritance_tree(self, location, max_age_allowed):
'''
TODO (cdodge) This method can be deleted when the 'split module store' work has been completed
'''
cache_name = '{0}/{1}'.format(location.org, location.course)
cache = self.metadata_inheritance_cache.get(cache_name,{'parent_metadata': {},
'timestamp': datetime.now() - timedelta(hours=1)})
age = (datetime.now() - cache['timestamp'])
if age.seconds >= max_age_allowed:
logging.debug('loading entire inheritance tree for {0}'.format(cache_name))
cache = self.get_metadata_inheritance_tree(location)
self.metadata_inheritance_cache[cache_name] = cache
return cache
def _clean_item_data(self, item): def _clean_item_data(self, item):
""" """
...@@ -196,6 +279,8 @@ class MongoModuleStore(ModuleStoreBase): ...@@ -196,6 +279,8 @@ class MongoModuleStore(ModuleStoreBase):
resource_fs = OSFS(root) resource_fs = OSFS(root)
# TODO (cdodge): When the 'split module store' work has been completed, we should remove
# the 'metadata_inheritance_tree' parameter
system = CachingDescriptorSystem( system = CachingDescriptorSystem(
self, self,
data_cache, data_cache,
...@@ -203,6 +288,7 @@ class MongoModuleStore(ModuleStoreBase): ...@@ -203,6 +288,7 @@ class MongoModuleStore(ModuleStoreBase):
resource_fs, resource_fs,
self.error_tracker, self.error_tracker,
self.render_template, self.render_template,
metadata_inheritance_tree = self.get_cached_metadata_inheritance_tree(Location(item['location']), 60)
) )
return system.load_item(item['location']) return system.load_item(item['location'])
...@@ -261,11 +347,11 @@ class MongoModuleStore(ModuleStoreBase): ...@@ -261,11 +347,11 @@ class MongoModuleStore(ModuleStoreBase):
descendents of the queried modules for more efficient results later descendents of the queried modules for more efficient results later
in the request. The depth is counted in the number of in the request. The depth is counted in the number of
calls to get_children() to cache. None indicates to cache all descendents. calls to get_children() to cache. None indicates to cache all descendents.
""" """
location = Location.ensure_fully_specified(location) location = Location.ensure_fully_specified(location)
item = self._find_one(location) item = self._find_one(location)
return self._load_items([item], depth)[0] module = self._load_items([item], depth)[0]
return module
def get_instance(self, course_id, location, depth=0): def get_instance(self, course_id, location, depth=0):
""" """
...@@ -285,7 +371,8 @@ class MongoModuleStore(ModuleStoreBase): ...@@ -285,7 +371,8 @@ class MongoModuleStore(ModuleStoreBase):
sort=[('revision', pymongo.ASCENDING)], sort=[('revision', pymongo.ASCENDING)],
) )
return self._load_items(list(items), depth) modules = self._load_items(list(items), depth)
return modules
def clone_item(self, source, location): def clone_item(self, source, location):
""" """
...@@ -313,7 +400,7 @@ class MongoModuleStore(ModuleStoreBase): ...@@ -313,7 +400,7 @@ class MongoModuleStore(ModuleStoreBase):
raise DuplicateItemError(location) raise DuplicateItemError(location)
def get_course_for_item(self, location): def get_course_for_item(self, location, depth=0):
''' '''
VS[compat] VS[compat]
cdodge: for a given Xmodule, return the course that it belongs to cdodge: for a given Xmodule, return the course that it belongs to
...@@ -327,7 +414,7 @@ class MongoModuleStore(ModuleStoreBase): ...@@ -327,7 +414,7 @@ class MongoModuleStore(ModuleStoreBase):
# know the 'name' parameter in this context, so we have # know the 'name' parameter in this context, so we have
# to assume there's only one item in this query even though we are not specifying a name # to assume there's only one item in this query even though we are not specifying a name
course_search_location = ['i4x', location.org, location.course, 'course', None] course_search_location = ['i4x', location.org, location.course, 'course', None]
courses = self.get_items(course_search_location) courses = self.get_items(course_search_location, depth=depth)
# make sure we found exactly one match on this above course search # make sure we found exactly one match on this above course search
found_cnt = len(courses) found_cnt = len(courses)
......
...@@ -2,8 +2,7 @@ import json ...@@ -2,8 +2,7 @@ import json
import logging import logging
from lxml import etree from lxml import etree
from lxml.html import rewrite_links from lxml.html import rewrite_links
from xmodule.timeinfo import TimeInfo
from xmodule.capa_module import only_one, ComplexEncoder from xmodule.capa_module import only_one, ComplexEncoder
from xmodule.editing_module import EditingDescriptor from xmodule.editing_module import EditingDescriptor
from xmodule.html_checker import check_html from xmodule.html_checker import check_html
...@@ -14,16 +13,13 @@ from xmodule.xml_module import XmlDescriptor ...@@ -14,16 +13,13 @@ from xmodule.xml_module import XmlDescriptor
import self_assessment_module import self_assessment_module
import open_ended_module import open_ended_module
from combined_open_ended_rubric import CombinedOpenEndedRubric, GRADER_TYPE_IMAGE_DICT, HUMAN_GRADER_TYPE, LEGEND_LIST from combined_open_ended_rubric import CombinedOpenEndedRubric, GRADER_TYPE_IMAGE_DICT, HUMAN_GRADER_TYPE, LEGEND_LIST
import dateutil
import dateutil.parser
from xmodule.timeparse import parse_timedelta
log = logging.getLogger("mitx.courseware") log = logging.getLogger("mitx.courseware")
# Set the default number of max attempts. Should be 1 for production # Set the default number of max attempts. Should be 1 for production
# Set higher for debugging/testing # Set higher for debugging/testing
# attempts specified in xml definition overrides this. # attempts specified in xml definition overrides this.
MAX_ATTEMPTS = 10000 MAX_ATTEMPTS = 1
# Set maximum available number of points. # Set maximum available number of points.
# Overriden by max_score specified in xml. # Overriden by max_score specified in xml.
...@@ -48,6 +44,10 @@ HUMAN_TASK_TYPE = { ...@@ -48,6 +44,10 @@ HUMAN_TASK_TYPE = {
'openended' : "edX Assessment", 'openended' : "edX Assessment",
} }
#Default value that controls whether or not to skip basic spelling checks in the controller
#Metadata overrides this
SKIP_BASIC_CHECKS = False
class CombinedOpenEndedV1Module(): class CombinedOpenEndedV1Module():
""" """
This is a module that encapsulates all open ended grading (self assessment, peer assessment, etc). This is a module that encapsulates all open ended grading (self assessment, peer assessment, etc).
...@@ -146,28 +146,17 @@ class CombinedOpenEndedV1Module(): ...@@ -146,28 +146,17 @@ class CombinedOpenEndedV1Module():
self.max_attempts = int(self.metadata.get('attempts', MAX_ATTEMPTS)) self.max_attempts = int(self.metadata.get('attempts', MAX_ATTEMPTS))
self.is_scored = self.metadata.get('is_graded', IS_SCORED) in TRUE_DICT self.is_scored = self.metadata.get('is_graded', IS_SCORED) in TRUE_DICT
self.accept_file_upload = self.metadata.get('accept_file_upload', ACCEPT_FILE_UPLOAD) in TRUE_DICT self.accept_file_upload = self.metadata.get('accept_file_upload', ACCEPT_FILE_UPLOAD) in TRUE_DICT
self.skip_basic_checks = self.metadata.get('skip_spelling_checks', SKIP_BASIC_CHECKS)
display_due_date_string = self.metadata.get('due', None) display_due_date_string = self.metadata.get('due', None)
if display_due_date_string is not None:
try:
self.display_due_date = dateutil.parser.parse(display_due_date_string)
except ValueError:
log.error("Could not parse due date {0} for location {1}".format(display_due_date_string, location))
raise
else:
self.display_due_date = None
grace_period_string = self.metadata.get('graceperiod', None) grace_period_string = self.metadata.get('graceperiod', None)
if grace_period_string is not None and self.display_due_date: try:
try: self.timeinfo = TimeInfo(display_due_date_string, grace_period_string)
self.grace_period = parse_timedelta(grace_period_string) except:
self.close_date = self.display_due_date + self.grace_period log.error("Error parsing due date information in location {0}".format(location))
except: raise
log.error("Error parsing the grace period {0} for location {1}".format(grace_period_string, location)) self.display_due_date = self.timeinfo.display_due_date
raise
else:
self.grace_period = None
self.close_date = self.display_due_date
# Used for progress / grading. Currently get credit just for # Used for progress / grading. Currently get credit just for
# completion (doesn't matter if you self-assessed correct/incorrect). # completion (doesn't matter if you self-assessed correct/incorrect).
...@@ -185,8 +174,9 @@ class CombinedOpenEndedV1Module(): ...@@ -185,8 +174,9 @@ class CombinedOpenEndedV1Module():
'rubric': definition['rubric'], 'rubric': definition['rubric'],
'display_name': self.display_name, 'display_name': self.display_name,
'accept_file_upload': self.accept_file_upload, 'accept_file_upload': self.accept_file_upload,
'close_date' : self.close_date, 'close_date' : self.timeinfo.close_date,
's3_interface' : self.system.s3_interface, 's3_interface' : self.system.s3_interface,
'skip_basic_checks' : self.skip_basic_checks,
} }
self.task_xml = definition['task_xml'] self.task_xml = definition['task_xml']
...@@ -340,6 +330,7 @@ class CombinedOpenEndedV1Module(): ...@@ -340,6 +330,7 @@ class CombinedOpenEndedV1Module():
'status': self.get_status(False), 'status': self.get_status(False),
'display_name': self.display_name, 'display_name': self.display_name,
'accept_file_upload': self.accept_file_upload, 'accept_file_upload': self.accept_file_upload,
'location': self.location,
'legend_list' : LEGEND_LIST, 'legend_list' : LEGEND_LIST,
} }
...@@ -656,7 +647,10 @@ class CombinedOpenEndedV1Module(): ...@@ -656,7 +647,10 @@ class CombinedOpenEndedV1Module():
if self.attempts > self.max_attempts: if self.attempts > self.max_attempts:
return { return {
'success': False, 'success': False,
'error': 'Too many attempts.' #This is a student_facing_error
'error': ('You have attempted this question {0} times. '
'You are only allowed to attempt it {1} times.').format(
self.attempts, self.max_attempts)
} }
self.state = self.INITIAL self.state = self.INITIAL
self.allow_reset = False self.allow_reset = False
...@@ -795,7 +789,8 @@ class CombinedOpenEndedV1Descriptor(XmlDescriptor, EditingDescriptor): ...@@ -795,7 +789,8 @@ class CombinedOpenEndedV1Descriptor(XmlDescriptor, EditingDescriptor):
expected_children = ['task', 'rubric', 'prompt'] expected_children = ['task', 'rubric', 'prompt']
for child in expected_children: for child in expected_children:
if len(xml_object.xpath(child)) == 0: if len(xml_object.xpath(child)) == 0:
raise ValueError("Combined Open Ended definition must include at least one '{0}' tag".format(child)) #This is a staff_facing_error
raise ValueError("Combined Open Ended definition must include at least one '{0}' tag. Contact the learning sciences group for assistance.".format(child))
def parse_task(k): def parse_task(k):
"""Assumes that xml_object has child k""" """Assumes that xml_object has child k"""
...@@ -820,4 +815,4 @@ class CombinedOpenEndedV1Descriptor(XmlDescriptor, EditingDescriptor): ...@@ -820,4 +815,4 @@ class CombinedOpenEndedV1Descriptor(XmlDescriptor, EditingDescriptor):
for child in ['task']: for child in ['task']:
add_child(child) add_child(child)
return elt return elt
\ No newline at end of file
...@@ -4,7 +4,6 @@ from lxml import etree ...@@ -4,7 +4,6 @@ from lxml import etree
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
GRADER_TYPE_IMAGE_DICT = { GRADER_TYPE_IMAGE_DICT = {
'8B' : '/static/images/random_grading_icon.png',
'SA' : '/static/images/self_assessment_icon.png', 'SA' : '/static/images/self_assessment_icon.png',
'PE' : '/static/images/peer_grading_icon.png', 'PE' : '/static/images/peer_grading_icon.png',
'ML' : '/static/images/ml_grading_icon.png', 'ML' : '/static/images/ml_grading_icon.png',
...@@ -13,7 +12,6 @@ GRADER_TYPE_IMAGE_DICT = { ...@@ -13,7 +12,6 @@ GRADER_TYPE_IMAGE_DICT = {
} }
HUMAN_GRADER_TYPE = { HUMAN_GRADER_TYPE = {
'8B' : 'Magic-8-Ball-Assessment',
'SA' : 'Self-Assessment', 'SA' : 'Self-Assessment',
'PE' : 'Peer-Assessment', 'PE' : 'Peer-Assessment',
'IN' : 'Instructor-Assessment', 'IN' : 'Instructor-Assessment',
...@@ -71,8 +69,9 @@ class CombinedOpenEndedRubric(object): ...@@ -71,8 +69,9 @@ class CombinedOpenEndedRubric(object):
}) })
success = True success = True
except: except:
error_message = "[render_rubric] Could not parse the rubric with xml: {0}".format(rubric_xml) #This is a staff_facing_error
log.error(error_message) error_message = "[render_rubric] Could not parse the rubric with xml: {0}. Contact the learning sciences group for assistance.".format(rubric_xml)
log.exception(error_message)
raise RubricParsingError(error_message) raise RubricParsingError(error_message)
return {'success' : success, 'html' : html, 'rubric_scores' : rubric_scores} return {'success' : success, 'html' : html, 'rubric_scores' : rubric_scores}
...@@ -81,7 +80,8 @@ class CombinedOpenEndedRubric(object): ...@@ -81,7 +80,8 @@ class CombinedOpenEndedRubric(object):
success = rubric_dict['success'] success = rubric_dict['success']
rubric_feedback = rubric_dict['html'] rubric_feedback = rubric_dict['html']
if not success: if not success:
error_message = "Could not parse rubric : {0} for location {1}".format(rubric_string, location.url()) #This is a staff_facing_error
error_message = "Could not parse rubric : {0} for location {1}. Contact the learning sciences group for assistance.".format(rubric_string, location.url())
log.error(error_message) log.error(error_message)
raise RubricParsingError(error_message) raise RubricParsingError(error_message)
...@@ -90,13 +90,15 @@ class CombinedOpenEndedRubric(object): ...@@ -90,13 +90,15 @@ class CombinedOpenEndedRubric(object):
for category in rubric_categories: for category in rubric_categories:
total = total + len(category['options']) - 1 total = total + len(category['options']) - 1
if len(category['options']) > (max_score_allowed + 1): if len(category['options']) > (max_score_allowed + 1):
error_message = "Number of score points in rubric {0} higher than the max allowed, which is {1}".format( #This is a staff_facing_error
error_message = "Number of score points in rubric {0} higher than the max allowed, which is {1}. Contact the learning sciences group for assistance.".format(
len(category['options']), max_score_allowed) len(category['options']), max_score_allowed)
log.error(error_message) log.error(error_message)
raise RubricParsingError(error_message) raise RubricParsingError(error_message)
if total != max_score: if total != max_score:
error_msg = "The max score {0} for problem {1} does not match the total number of points in the rubric {2}".format( #This is a staff_facing_error
error_msg = "The max score {0} for problem {1} does not match the total number of points in the rubric {2}. Contact the learning sciences group for assistance.".format(
max_score, location, total) max_score, location, total)
log.error(error_msg) log.error(error_msg)
raise RubricParsingError(error_msg) raise RubricParsingError(error_msg)
...@@ -118,7 +120,8 @@ class CombinedOpenEndedRubric(object): ...@@ -118,7 +120,8 @@ class CombinedOpenEndedRubric(object):
categories = [] categories = []
for category in element: for category in element:
if category.tag != 'category': if category.tag != 'category':
raise RubricParsingError("[extract_categories] Expected a <category> tag: got {0} instead".format(category.tag)) #This is a staff_facing_error
raise RubricParsingError("[extract_categories] Expected a <category> tag: got {0} instead. Contact the learning sciences group for assistance.".format(category.tag))
else: else:
categories.append(self.extract_category(category)) categories.append(self.extract_category(category))
return categories return categories
...@@ -144,12 +147,14 @@ class CombinedOpenEndedRubric(object): ...@@ -144,12 +147,14 @@ class CombinedOpenEndedRubric(object):
self.has_score = True self.has_score = True
# if we are missing the score tag and we are expecting one # if we are missing the score tag and we are expecting one
elif self.has_score: elif self.has_score:
raise RubricParsingError("[extract_category] Category {0} is missing a score".format(descriptionxml.text)) #This is a staff_facing_error
raise RubricParsingError("[extract_category] Category {0} is missing a score. Contact the learning sciences group for assistance.".format(descriptionxml.text))
# parse description # parse description
if descriptionxml.tag != 'description': if descriptionxml.tag != 'description':
raise RubricParsingError("[extract_category]: expected description tag, got {0} instead".format(descriptionxml.tag)) #This is a staff_facing_error
raise RubricParsingError("[extract_category]: expected description tag, got {0} instead. Contact the learning sciences group for assistance.".format(descriptionxml.tag))
description = descriptionxml.text description = descriptionxml.text
...@@ -159,7 +164,8 @@ class CombinedOpenEndedRubric(object): ...@@ -159,7 +164,8 @@ class CombinedOpenEndedRubric(object):
# parse options # parse options
for option in optionsxml: for option in optionsxml:
if option.tag != 'option': if option.tag != 'option':
raise RubricParsingError("[extract_category]: expected option tag, got {0} instead".format(option.tag)) #This is a staff_facing_error
raise RubricParsingError("[extract_category]: expected option tag, got {0} instead. Contact the learning sciences group for assistance.".format(option.tag))
else: else:
pointstr = option.get("points") pointstr = option.get("points")
if pointstr: if pointstr:
...@@ -168,7 +174,8 @@ class CombinedOpenEndedRubric(object): ...@@ -168,7 +174,8 @@ class CombinedOpenEndedRubric(object):
try: try:
points = int(pointstr) points = int(pointstr)
except ValueError: except ValueError:
raise RubricParsingError("[extract_category]: expected points to have int, got {0} instead".format(pointstr)) #This is a staff_facing_error
raise RubricParsingError("[extract_category]: expected points to have int, got {0} instead. Contact the learning sciences group for assistance.".format(pointstr))
elif autonumbering: elif autonumbering:
# use the generated one if we're in the right mode # use the generated one if we're in the right mode
points = cur_points points = cur_points
...@@ -200,7 +207,6 @@ class CombinedOpenEndedRubric(object): ...@@ -200,7 +207,6 @@ class CombinedOpenEndedRubric(object):
for grader_type in tuple[3]: for grader_type in tuple[3]:
rubric_categories[i]['options'][j]['grader_types'].append(grader_type) rubric_categories[i]['options'][j]['grader_types'].append(grader_type)
log.debug(rubric_categories)
html = self.system.render_template('open_ended_combined_rubric.html', html = self.system.render_template('open_ended_combined_rubric.html',
{'categories': rubric_categories, {'categories': rubric_categories,
'has_score': True, 'has_score': True,
...@@ -219,13 +225,15 @@ class CombinedOpenEndedRubric(object): ...@@ -219,13 +225,15 @@ class CombinedOpenEndedRubric(object):
Validates a set of options. This can and should be extended to filter out other bad edge cases Validates a set of options. This can and should be extended to filter out other bad edge cases
''' '''
if len(options) == 0: if len(options) == 0:
raise RubricParsingError("[extract_category]: no options associated with this category") #This is a staff_facing_error
raise RubricParsingError("[extract_category]: no options associated with this category. Contact the learning sciences group for assistance.")
if len(options) == 1: if len(options) == 1:
return return
prev = options[0]['points'] prev = options[0]['points']
for option in options[1:]: for option in options[1:]:
if prev == option['points']: if prev == option['points']:
raise RubricParsingError("[extract_category]: found duplicate point values between two different options") #This is a staff_facing_error
raise RubricParsingError("[extract_category]: found duplicate point values between two different options. Contact the learning sciences group for assistance.")
else: else:
prev = option['points'] prev = option['points']
...@@ -241,11 +249,14 @@ class CombinedOpenEndedRubric(object): ...@@ -241,11 +249,14 @@ class CombinedOpenEndedRubric(object):
""" """
success = False success = False
if len(scores)==0: if len(scores)==0:
log.error("Score length is 0.") #This is a dev_facing_error
log.error("Score length is 0 when trying to reformat rubric scores for rendering.")
return success, "" return success, ""
if len(scores) != len(score_types) or len(feedback_types) != len(scores): if len(scores) != len(score_types) or len(feedback_types) != len(scores):
log.error("Length mismatches.") #This is a dev_facing_error
log.error("Length mismatches when trying to reformat rubric scores for rendering. "
"Scores: {0}, Score Types: {1} Feedback Types: {2}".format(scores, score_types, feedback_types))
return success, "" return success, ""
score_lists = [] score_lists = []
......
import logging import logging
from xmodule.open_ended_grading_classes.grading_service_module import GradingService from grading_service_module import GradingService
from xmodule.x_module import ModuleSystem
from mitxmako.shortcuts import render_to_string
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -11,8 +8,8 @@ class ControllerQueryService(GradingService): ...@@ -11,8 +8,8 @@ class ControllerQueryService(GradingService):
""" """
Interface to staff grading backend. Interface to staff grading backend.
""" """
def __init__(self, config): def __init__(self, config, system):
config['system'] = ModuleSystem(None, None, None, render_to_string, None) config['system'] = system
super(ControllerQueryService, self).__init__(config) super(ControllerQueryService, self).__init__(config)
self.url = config['url'] + config['grading_controller'] self.url = config['url'] + config['grading_controller']
self.login_url = self.url + '/login/' self.login_url = self.url + '/login/'
...@@ -77,3 +74,16 @@ class ControllerQueryService(GradingService): ...@@ -77,3 +74,16 @@ class ControllerQueryService(GradingService):
response = self.post(self.take_action_on_flags_url, params) response = self.post(self.take_action_on_flags_url, params)
return response return response
def convert_seconds_to_human_readable(seconds):
if seconds < 60:
human_string = "{0} seconds".format(seconds)
elif seconds < 60 * 60:
human_string = "{0} minutes".format(round(seconds/60,1))
elif seconds < (24*60*60):
human_string = "{0} hours".format(round(seconds/(60*60),1))
else:
human_string = "{0} days".format(round(seconds/(60*60*24),1))
eta_string = "{0}".format(human_string)
return eta_string
...@@ -51,6 +51,8 @@ class GradingService(object): ...@@ -51,6 +51,8 @@ class GradingService(object):
r = self._try_with_login(op) r = self._try_with_login(op)
except (RequestException, ConnectionError, HTTPError) as err: except (RequestException, ConnectionError, HTTPError) as err:
# reraise as promised GradingServiceError, but preserve stacktrace. # reraise as promised GradingServiceError, but preserve stacktrace.
#This is a dev_facing_error
log.error("Problem posting data to the grading controller. URL: {0}, data: {1}".format(url, data))
raise GradingServiceError, str(err), sys.exc_info()[2] raise GradingServiceError, str(err), sys.exc_info()[2]
return r.text return r.text
...@@ -67,6 +69,8 @@ class GradingService(object): ...@@ -67,6 +69,8 @@ class GradingService(object):
r = self._try_with_login(op) r = self._try_with_login(op)
except (RequestException, ConnectionError, HTTPError) as err: except (RequestException, ConnectionError, HTTPError) as err:
# reraise as promised GradingServiceError, but preserve stacktrace. # reraise as promised GradingServiceError, but preserve stacktrace.
#This is a dev_facing_error
log.error("Problem getting data from the grading controller. URL: {0}, params: {1}".format(url, params))
raise GradingServiceError, str(err), sys.exc_info()[2] raise GradingServiceError, str(err), sys.exc_info()[2]
return r.text return r.text
...@@ -119,11 +123,13 @@ class GradingService(object): ...@@ -119,11 +123,13 @@ class GradingService(object):
return response_json return response_json
# if we can't parse the rubric into HTML, # if we can't parse the rubric into HTML,
except etree.XMLSyntaxError, RubricParsingError: except etree.XMLSyntaxError, RubricParsingError:
#This is a dev_facing_error
log.exception("Cannot parse rubric string. Raw string: {0}" log.exception("Cannot parse rubric string. Raw string: {0}"
.format(rubric)) .format(rubric))
return {'success': False, return {'success': False,
'error': 'Error displaying submission'} 'error': 'Error displaying submission'}
except ValueError: except ValueError:
#This is a dev_facing_error
log.exception("Error parsing response: {0}".format(response)) log.exception("Error parsing response: {0}".format(response))
return {'success': False, return {'success': False,
'error': "Error displaying submission"} 'error': "Error displaying submission"}
...@@ -251,8 +251,9 @@ def upload_to_s3(file_to_upload, keyname, s3_interface): ...@@ -251,8 +251,9 @@ def upload_to_s3(file_to_upload, keyname, s3_interface):
return True, public_url return True, public_url
except: except:
error_message = "Could not connect to S3." #This is a dev_facing_error
log.exception(error_message) error_message = "Could not connect to S3 to upload peer grading image. Trying to utilize bucket: {0}".format(bucketname.lower())
log.error(error_message)
return False, error_message return False, error_message
......
...@@ -22,6 +22,8 @@ from xmodule.stringify import stringify_children ...@@ -22,6 +22,8 @@ from xmodule.stringify import stringify_children
from xmodule.xml_module import XmlDescriptor from xmodule.xml_module import XmlDescriptor
from xmodule.modulestore import Location from xmodule.modulestore import Location
from capa.util import * from capa.util import *
from peer_grading_service import PeerGradingService
import controller_query_service
from datetime import datetime from datetime import datetime
...@@ -99,10 +101,21 @@ class OpenEndedChild(object): ...@@ -99,10 +101,21 @@ class OpenEndedChild(object):
self.accept_file_upload = static_data['accept_file_upload'] self.accept_file_upload = static_data['accept_file_upload']
self.close_date = static_data['close_date'] self.close_date = static_data['close_date']
self.s3_interface = static_data['s3_interface'] self.s3_interface = static_data['s3_interface']
self.skip_basic_checks = static_data['skip_basic_checks']
# Used for progress / grading. Currently get credit just for # Used for progress / grading. Currently get credit just for
# completion (doesn't matter if you self-assessed correct/incorrect). # completion (doesn't matter if you self-assessed correct/incorrect).
self._max_score = static_data['max_score'] self._max_score = static_data['max_score']
self.peer_gs = PeerGradingService(system.open_ended_grading_interface, system)
self.controller_qs = controller_query_service.ControllerQueryService(system.open_ended_grading_interface,system)
self.system = system
self.location_string = location
try:
self.location_string = self.location_string.url()
except:
pass
self.setup_response(system, location, definition, descriptor) self.setup_response(system, location, definition, descriptor)
...@@ -126,12 +139,14 @@ class OpenEndedChild(object): ...@@ -126,12 +139,14 @@ class OpenEndedChild(object):
if self.closed(): if self.closed():
return True, { return True, {
'success': False, 'success': False,
'error': 'This problem is now closed.' #This is a student_facing_error
'error': 'The problem close date has passed, and this problem is now closed.'
} }
elif self.attempts > self.max_attempts: elif self.attempts > self.max_attempts:
return True, { return True, {
'success': False, 'success': False,
'error': 'Too many attempts.' #This is a student_facing_error
'error': 'You have attempted this problem {0} times. You are allowed {1} attempts.'.format(self.attempts, self.max_attempts)
} }
else: else:
return False, {} return False, {}
...@@ -250,7 +265,8 @@ class OpenEndedChild(object): ...@@ -250,7 +265,8 @@ class OpenEndedChild(object):
try: try:
return Progress(self.get_score()['score'], self._max_score) return Progress(self.get_score()['score'], self._max_score)
except Exception as err: except Exception as err:
log.exception("Got bad progress") #This is a dev_facing_error
log.exception("Got bad progress from open ended child module. Max Score: {1}".format(self._max_score))
return None return None
return None return None
...@@ -258,10 +274,12 @@ class OpenEndedChild(object): ...@@ -258,10 +274,12 @@ class OpenEndedChild(object):
""" """
return dict out-of-sync error message, and also log. return dict out-of-sync error message, and also log.
""" """
log.warning("Assessment module state out sync. state: %r, get: %r. %s", #This is a dev_facing_error
log.warning("Open ended child state out sync. state: %r, get: %r. %s",
self.state, get, msg) self.state, get, msg)
#This is a student_facing_error
return {'success': False, return {'success': False,
'error': 'The problem state got out-of-sync'} 'error': 'The problem state got out-of-sync. Please try reloading the page.'}
def get_html(self): def get_html(self):
""" """
...@@ -339,6 +357,10 @@ class OpenEndedChild(object): ...@@ -339,6 +357,10 @@ class OpenEndedChild(object):
if get_data['can_upload_files'] in ['true', '1']: if get_data['can_upload_files'] in ['true', '1']:
has_file_to_upload = True has_file_to_upload = True
file = get_data['student_file'][0] file = get_data['student_file'][0]
if self.system.track_fuction:
self.system.track_function('open_ended_image_upload', {'filename': file.name})
else:
log.info("No tracking function found when uploading image.")
uploaded_to_s3, image_ok, s3_public_url = self.upload_image_to_s3(file) uploaded_to_s3, image_ok, s3_public_url = self.upload_image_to_s3(file)
if uploaded_to_s3: if uploaded_to_s3:
image_tag = self.generate_image_tag_from_url(s3_public_url, file.name) image_tag = self.generate_image_tag_from_url(s3_public_url, file.name)
...@@ -407,3 +429,55 @@ class OpenEndedChild(object): ...@@ -407,3 +429,55 @@ class OpenEndedChild(object):
success = True success = True
return success, string return success, string
def check_if_student_can_submit(self):
location = self.location_string
student_id = self.system.anonymous_student_id
success = False
allowed_to_submit = True
response = {}
#This is a student_facing_error
error_string = ("You need to peer grade {0} more in order to make another submission. "
"You have graded {1}, and {2} are required. You have made {3} successful peer grading submissions.")
try:
response = self.peer_gs.get_data_for_location(self.location_string, student_id)
count_graded = response['count_graded']
count_required = response['count_required']
student_sub_count = response['student_sub_count']
success = True
except:
#This is a dev_facing_error
log.error("Could not contact external open ended graders for location {0} and student {1}".format(self.location_string,student_id))
#This is a student_facing_error
error_message = "Could not contact the graders. Please notify course staff."
return success, allowed_to_submit, error_message
if count_graded>=count_required:
return success, allowed_to_submit, ""
else:
allowed_to_submit = False
#This is a student_facing_error
error_message = error_string.format(count_required-count_graded, count_graded, count_required, student_sub_count)
return success, allowed_to_submit, error_message
def get_eta(self):
response = self.controller_qs.check_for_eta(self.location_string)
try:
response = json.loads(response)
except:
pass
success = response['success']
if isinstance(success, basestring):
success = (success.lower()=="true")
if success:
eta = controller_query_service.convert_seconds_to_human_readable(response['eta'])
eta_string = "Please check back for your response in at most {0}.".format(eta)
else:
eta_string = ""
return eta_string
...@@ -30,8 +30,8 @@ class PeerGradingService(GradingService): ...@@ -30,8 +30,8 @@ class PeerGradingService(GradingService):
self.system = system self.system = system
def get_data_for_location(self, problem_location, student_id): def get_data_for_location(self, problem_location, student_id):
response = self.get(self.get_data_for_location_url, params = {'location': problem_location, 'student_id': student_id}
{'location': problem_location, 'student_id': student_id}) response = self.get(self.get_data_for_location_url, params)
return self.try_to_decode(response) return self.try_to_decode(response)
def get_next_submission(self, problem_location, grader_id): def get_next_submission(self, problem_location, grader_id):
...@@ -106,7 +106,7 @@ class MockPeerGradingService(object): ...@@ -106,7 +106,7 @@ class MockPeerGradingService(object):
'max_score': 4}) 'max_score': 4})
def save_grade(self, location, grader_id, submission_id, def save_grade(self, location, grader_id, submission_id,
score, feedback, submission_key): score, feedback, submission_key, rubric_scores, submission_flagged):
return json.dumps({'success': True}) return json.dumps({'success': True})
def is_student_calibrated(self, problem_location, grader_id): def is_student_calibrated(self, problem_location, grader_id):
...@@ -122,7 +122,8 @@ class MockPeerGradingService(object): ...@@ -122,7 +122,8 @@ class MockPeerGradingService(object):
'max_score': 4}) 'max_score': 4})
def save_calibration_essay(self, problem_location, grader_id, def save_calibration_essay(self, problem_location, grader_id,
calibration_essay_id, submission_key, score, feedback): calibration_essay_id, submission_key, score,
feedback, rubric_scores):
return {'success': True, 'actual_score': 2} return {'success': True, 'actual_score': 2}
def get_problem_list(self, course_id, grader_id): def get_problem_list(self, course_id, grader_id):
......
...@@ -90,7 +90,10 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): ...@@ -90,7 +90,10 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
} }
if dispatch not in handlers: if dispatch not in handlers:
return 'Error' #This is a dev_facing_error
log.error("Cannot find {0} in handlers in handle_ajax function for open_ended_module.py".format(dispatch))
#This is a dev_facing_error
return json.dumps({'error': 'Error handling action. Please try again.', 'success' : False})
before = self.get_progress() before = self.get_progress()
d = handlers[dispatch](get, system) d = handlers[dispatch](get, system)
...@@ -123,7 +126,8 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): ...@@ -123,7 +126,8 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
elif self.state in (self.POST_ASSESSMENT, self.DONE): elif self.state in (self.POST_ASSESSMENT, self.DONE):
context['read_only'] = True context['read_only'] = True
else: else:
raise ValueError("Illegal state '%r'" % self.state) #This is a dev_facing_error
raise ValueError("Self assessment module is in an illegal state '{0}'".format(self.state))
return system.render_template('self_assessment_rubric.html', context) return system.render_template('self_assessment_rubric.html', context)
...@@ -148,7 +152,8 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): ...@@ -148,7 +152,8 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
elif self.state == self.DONE: elif self.state == self.DONE:
context['read_only'] = True context['read_only'] = True
else: else:
raise ValueError("Illegal state '%r'" % self.state) #This is a dev_facing_error
raise ValueError("Self Assessment module is in an illegal state '{0}'".format(self.state))
return system.render_template('self_assessment_hint.html', context) return system.render_template('self_assessment_hint.html', context)
...@@ -177,10 +182,16 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): ...@@ -177,10 +182,16 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
# add new history element with answer and empty score and hint. # add new history element with answer and empty score and hint.
success, get = self.append_image_to_student_answer(get) success, get = self.append_image_to_student_answer(get)
if success: if success:
get['student_answer'] = SelfAssessmentModule.sanitize_html(get['student_answer']) success, allowed_to_submit, error_message = self.check_if_student_can_submit()
self.new_history_entry(get['student_answer']) if allowed_to_submit:
self.change_state(self.ASSESSING) get['student_answer'] = SelfAssessmentModule.sanitize_html(get['student_answer'])
self.new_history_entry(get['student_answer'])
self.change_state(self.ASSESSING)
else:
#Error message already defined
success = False
else: else:
#This is a student_facing_error
error_message = "There was a problem saving the image in your submission. Please try a different image, or try pasting a link to an image into the answer box." error_message = "There was a problem saving the image in your submission. Please try a different image, or try pasting a link to an image into the answer box."
return { return {
...@@ -214,7 +225,10 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): ...@@ -214,7 +225,10 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
for i in xrange(0,len(score_list)): for i in xrange(0,len(score_list)):
score_list[i] = int(score_list[i]) score_list[i] = int(score_list[i])
except ValueError: except ValueError:
return {'success': False, 'error': "Non-integer score value, or no score list"} #This is a dev_facing_error
log.error("Non-integer score value passed to save_assessment ,or no score list present.")
#This is a student_facing_error
return {'success': False, 'error': "Error saving your score. Please notify course staff."}
#Record score as assessment and rubric scores as post assessment #Record score as assessment and rubric scores as post assessment
self.record_latest_score(score) self.record_latest_score(score)
...@@ -256,6 +270,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): ...@@ -256,6 +270,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
try: try:
rubric_scores = json.loads(latest_post_assessment) rubric_scores = json.loads(latest_post_assessment)
except: except:
#This is a dev_facing_error
log.error("Cannot parse rubric scores in self assessment module from {0}".format(latest_post_assessment)) log.error("Cannot parse rubric scores in self assessment module from {0}".format(latest_post_assessment))
rubric_scores = [] rubric_scores = []
return [rubric_scores] return [rubric_scores]
...@@ -287,7 +302,8 @@ class SelfAssessmentDescriptor(XmlDescriptor, EditingDescriptor): ...@@ -287,7 +302,8 @@ class SelfAssessmentDescriptor(XmlDescriptor, EditingDescriptor):
expected_children = [] expected_children = []
for child in expected_children: for child in expected_children:
if len(xml_object.xpath(child)) != 1: if len(xml_object.xpath(child)) != 1:
raise ValueError("Self assessment definition must include exactly one '{0}' tag".format(child)) #This is a staff_facing_error
raise ValueError("Self assessment definition must include exactly one '{0}' tag. Contact the learning sciences group for assistance.".format(child))
def parse(k): def parse(k):
"""Assumes that xml_object has child k""" """Assumes that xml_object has child k"""
......
...@@ -19,6 +19,15 @@ import xmodule ...@@ -19,6 +19,15 @@ import xmodule
from xmodule.x_module import ModuleSystem from xmodule.x_module import ModuleSystem
from mock import Mock from mock import Mock
open_ended_grading_interface = {
'url': 'http://sandbox-grader-001.m.edx.org/peer_grading',
'username': 'incorrect_user',
'password': 'incorrect_pass',
'staff_grading' : 'staff_grading',
'peer_grading' : 'peer_grading',
'grading_controller' : 'grading_controller'
}
test_system = ModuleSystem( test_system = ModuleSystem(
ajax_url='courses/course_id/modx/a_location', ajax_url='courses/course_id/modx/a_location',
track_function=Mock(), track_function=Mock(),
...@@ -31,7 +40,8 @@ test_system = ModuleSystem( ...@@ -31,7 +40,8 @@ test_system = ModuleSystem(
debug=True, debug=True,
xqueue={'interface': None, 'callback_url': '/', 'default_queuename': 'testqueue', 'waittime': 10}, xqueue={'interface': None, 'callback_url': '/', 'default_queuename': 'testqueue', 'waittime': 10},
node_path=os.environ.get("NODE_PATH", "/usr/local/lib/node_modules"), node_path=os.environ.get("NODE_PATH", "/usr/local/lib/node_modules"),
anonymous_student_id='student' anonymous_student_id='student',
open_ended_grading_interface= open_ended_grading_interface
) )
......
...@@ -42,6 +42,7 @@ class CapaFactory(object): ...@@ -42,6 +42,7 @@ class CapaFactory(object):
force_save_button=None, force_save_button=None,
attempts=None, attempts=None,
problem_state=None, problem_state=None,
correct=False
): ):
""" """
All parameters are optional, and are added to the created problem if specified. All parameters are optional, and are added to the created problem if specified.
...@@ -58,6 +59,7 @@ class CapaFactory(object): ...@@ -58,6 +59,7 @@ class CapaFactory(object):
module. module.
attempts: also added to instance state. Will be converted to an int. attempts: also added to instance state. Will be converted to an int.
correct: if True, the problem will be initialized to be answered correctly.
""" """
definition = {'data': CapaFactory.sample_problem_xml, } definition = {'data': CapaFactory.sample_problem_xml, }
location = Location(["i4x", "edX", "capa_test", "problem", location = Location(["i4x", "edX", "capa_test", "problem",
...@@ -81,10 +83,19 @@ class CapaFactory(object): ...@@ -81,10 +83,19 @@ class CapaFactory(object):
instance_state_dict = {} instance_state_dict = {}
if problem_state is not None: if problem_state is not None:
instance_state_dict = problem_state instance_state_dict = problem_state
if attempts is not None: if attempts is not None:
# converting to int here because I keep putting "0" and "1" in the tests # converting to int here because I keep putting "0" and "1" in the tests
# since everything else is a string. # since everything else is a string.
instance_state_dict['attempts'] = int(attempts) instance_state_dict['attempts'] = int(attempts)
if correct:
# TODO: make this actually set an answer of 3.14, and mark it correct
#instance_state_dict['student_answers'] = {}
#instance_state_dict['correct_map'] = {}
pass
if len(instance_state_dict) > 0: if len(instance_state_dict) > 0:
instance_state = json.dumps(instance_state_dict) instance_state = json.dumps(instance_state_dict)
else: else:
...@@ -94,13 +105,16 @@ class CapaFactory(object): ...@@ -94,13 +105,16 @@ class CapaFactory(object):
definition, descriptor, definition, descriptor,
instance_state, None, metadata=metadata) instance_state, None, metadata=metadata)
if correct:
# TODO: probably better to actually set the internal state properly, but...
module.get_score = lambda: {'score': 1, 'total': 1}
return module return module
class CapaModuleTest(unittest.TestCase): class CapaModuleTest(unittest.TestCase):
def setUp(self): def setUp(self):
now = datetime.datetime.now() now = datetime.datetime.now()
day_delta = datetime.timedelta(days=1) day_delta = datetime.timedelta(days=1)
...@@ -120,6 +134,18 @@ class CapaModuleTest(unittest.TestCase): ...@@ -120,6 +134,18 @@ class CapaModuleTest(unittest.TestCase):
self.assertNotEqual(module.url_name, other_module.url_name, self.assertNotEqual(module.url_name, other_module.url_name,
"Factory should be creating unique names for each problem") "Factory should be creating unique names for each problem")
def test_correct(self):
"""
Check that the factory creates correct and incorrect problems properly.
"""
module = CapaFactory.create()
self.assertEqual(module.get_score()['score'], 0)
other_module = CapaFactory.create(correct=True)
self.assertEqual(other_module.get_score()['score'], 1)
def test_showanswer_default(self): def test_showanswer_default(self):
""" """
Make sure the show answer logic does the right thing. Make sure the show answer logic does the right thing.
...@@ -178,7 +204,7 @@ class CapaModuleTest(unittest.TestCase): ...@@ -178,7 +204,7 @@ class CapaModuleTest(unittest.TestCase):
for everyone--e.g. after due date + grace period. for everyone--e.g. after due date + grace period.
""" """
# can see after attempts used up, even with due date in the future # can't see after attempts used up, even with due date in the future
used_all_attempts = CapaFactory.create(showanswer='past_due', used_all_attempts = CapaFactory.create(showanswer='past_due',
max_attempts="1", max_attempts="1",
attempts="1", attempts="1",
...@@ -209,3 +235,50 @@ class CapaModuleTest(unittest.TestCase): ...@@ -209,3 +235,50 @@ class CapaModuleTest(unittest.TestCase):
due=self.yesterday_str, due=self.yesterday_str,
graceperiod=self.two_day_delta_str) graceperiod=self.two_day_delta_str)
self.assertFalse(still_in_grace.answer_available()) self.assertFalse(still_in_grace.answer_available())
def test_showanswer_finished(self):
"""
With showanswer="finished" should show answer after the problem is closed,
or after the answer is correct.
"""
# can see after attempts used up, even with due date in the future
used_all_attempts = CapaFactory.create(showanswer='finished',
max_attempts="1",
attempts="1",
due=self.tomorrow_str)
self.assertTrue(used_all_attempts.answer_available())
# can see after due date
past_due_date = CapaFactory.create(showanswer='finished',
max_attempts="1",
attempts="0",
due=self.yesterday_str)
self.assertTrue(past_due_date.answer_available())
# can't see because attempts left and wrong
attempts_left_open = CapaFactory.create(showanswer='finished',
max_attempts="1",
attempts="0",
due=self.tomorrow_str)
self.assertFalse(attempts_left_open.answer_available())
# _can_ see because attempts left and right
correct_ans = CapaFactory.create(showanswer='finished',
max_attempts="1",
attempts="0",
due=self.tomorrow_str,
correct=True)
self.assertTrue(correct_ans.answer_available())
# Can see even though grace period hasn't expired, because have no more
# attempts.
still_in_grace = CapaFactory.create(showanswer='finished',
max_attempts="1",
attempts="1",
due=self.yesterday_str,
graceperiod=self.two_day_delta_str)
self.assertTrue(still_in_grace.answer_available())
...@@ -48,6 +48,7 @@ class OpenEndedChildTest(unittest.TestCase): ...@@ -48,6 +48,7 @@ class OpenEndedChildTest(unittest.TestCase):
'close_date': None, 'close_date': None,
's3_interface' : "", 's3_interface' : "",
'open_ended_grading_interface' : {}, 'open_ended_grading_interface' : {},
'skip_basic_checks' : False,
} }
definition = Mock() definition = Mock()
descriptor = Mock() descriptor = Mock()
...@@ -167,6 +168,7 @@ class OpenEndedModuleTest(unittest.TestCase): ...@@ -167,6 +168,7 @@ class OpenEndedModuleTest(unittest.TestCase):
'close_date': None, 'close_date': None,
's3_interface' : test_util_open_ended.S3_INTERFACE, 's3_interface' : test_util_open_ended.S3_INTERFACE,
'open_ended_grading_interface' : test_util_open_ended.OPEN_ENDED_GRADING_INTERFACE, 'open_ended_grading_interface' : test_util_open_ended.OPEN_ENDED_GRADING_INTERFACE,
'skip_basic_checks' : False,
} }
oeparam = etree.XML(''' oeparam = etree.XML('''
...@@ -301,6 +303,7 @@ class CombinedOpenEndedModuleTest(unittest.TestCase): ...@@ -301,6 +303,7 @@ class CombinedOpenEndedModuleTest(unittest.TestCase):
'close_date' : "", 'close_date' : "",
's3_interface' : test_util_open_ended.S3_INTERFACE, 's3_interface' : test_util_open_ended.S3_INTERFACE,
'open_ended_grading_interface' : test_util_open_ended.OPEN_ENDED_GRADING_INTERFACE, 'open_ended_grading_interface' : test_util_open_ended.OPEN_ENDED_GRADING_INTERFACE,
'skip_basic_checks' : False,
} }
oeparam = etree.XML(''' oeparam = etree.XML('''
......
...@@ -4,7 +4,7 @@ from fs.osfs import OSFS ...@@ -4,7 +4,7 @@ from fs.osfs import OSFS
from nose.tools import assert_equals, assert_true from nose.tools import assert_equals, assert_true
from path import path from path import path
from tempfile import mkdtemp from tempfile import mkdtemp
from shutil import copytree import shutil
from xmodule.modulestore.xml import XMLModuleStore from xmodule.modulestore.xml import XMLModuleStore
...@@ -46,11 +46,11 @@ class RoundTripTestCase(unittest.TestCase): ...@@ -46,11 +46,11 @@ class RoundTripTestCase(unittest.TestCase):
Thus we make sure that export and import work properly. Thus we make sure that export and import work properly.
''' '''
def check_export_roundtrip(self, data_dir, course_dir): def check_export_roundtrip(self, data_dir, course_dir):
root_dir = path(mkdtemp()) root_dir = path(self.temp_dir)
print "Copying test course to temp dir {0}".format(root_dir) print "Copying test course to temp dir {0}".format(root_dir)
data_dir = path(data_dir) data_dir = path(data_dir)
copytree(data_dir / course_dir, root_dir / course_dir) shutil.copytree(data_dir / course_dir, root_dir / course_dir)
print "Starting import" print "Starting import"
initial_import = XMLModuleStore(root_dir, course_dirs=[course_dir]) initial_import = XMLModuleStore(root_dir, course_dirs=[course_dir])
...@@ -108,6 +108,8 @@ class RoundTripTestCase(unittest.TestCase): ...@@ -108,6 +108,8 @@ class RoundTripTestCase(unittest.TestCase):
def setUp(self): def setUp(self):
self.maxDiff = None self.maxDiff = None
self.temp_dir = mkdtemp()
self.addCleanup(shutil.rmtree, self.temp_dir)
def test_toy_roundtrip(self): def test_toy_roundtrip(self):
self.check_export_roundtrip(DATA_DIR, "toy") self.check_export_roundtrip(DATA_DIR, "toy")
......
import json import json
from mock import Mock from mock import Mock, MagicMock
import unittest import unittest
from xmodule.open_ended_grading_classes.self_assessment_module import SelfAssessmentModule from xmodule.open_ended_grading_classes.self_assessment_module import SelfAssessmentModule
from xmodule.modulestore import Location from xmodule.modulestore import Location
from lxml import etree from lxml import etree
from nose.plugins.skip import SkipTest
from . import test_system from . import test_system
...@@ -51,6 +50,7 @@ class SelfAssessmentTest(unittest.TestCase): ...@@ -51,6 +50,7 @@ class SelfAssessmentTest(unittest.TestCase):
'close_date': None, 'close_date': None,
's3_interface' : test_util_open_ended.S3_INTERFACE, 's3_interface' : test_util_open_ended.S3_INTERFACE,
'open_ended_grading_interface' : test_util_open_ended.OPEN_ENDED_GRADING_INTERFACE, 'open_ended_grading_interface' : test_util_open_ended.OPEN_ENDED_GRADING_INTERFACE,
'skip_basic_checks' : False,
} }
self.module = SelfAssessmentModule(test_system, self.location, self.module = SelfAssessmentModule(test_system, self.location,
...@@ -63,13 +63,29 @@ class SelfAssessmentTest(unittest.TestCase): ...@@ -63,13 +63,29 @@ class SelfAssessmentTest(unittest.TestCase):
self.assertTrue("This is sample prompt text" in html) self.assertTrue("This is sample prompt text" in html)
def test_self_assessment_flow(self): def test_self_assessment_flow(self):
raise SkipTest() responses = {'assessment': '0', 'score_list[]': ['0', '0']}
def get_fake_item(name):
return responses[name]
def get_data_for_location(self,location,student):
return {
'count_graded' : 0,
'count_required' : 0,
'student_sub_count': 0,
}
mock_query_dict = MagicMock()
mock_query_dict.__getitem__.side_effect = get_fake_item
mock_query_dict.getlist = get_fake_item
self.module.peer_gs.get_data_for_location = get_data_for_location
self.assertEqual(self.module.get_score()['score'], 0) self.assertEqual(self.module.get_score()['score'], 0)
self.module.save_answer({'student_answer': "I am an answer"}, test_system) self.module.save_answer({'student_answer': "I am an answer"}, test_system)
self.assertEqual(self.module.state, self.module.ASSESSING) self.assertEqual(self.module.state, self.module.ASSESSING)
self.module.save_assessment({'assessment': '0'}, test_system) self.module.save_assessment(mock_query_dict, test_system)
self.assertEqual(self.module.state, self.module.DONE) self.assertEqual(self.module.state, self.module.DONE)
...@@ -79,5 +95,6 @@ class SelfAssessmentTest(unittest.TestCase): ...@@ -79,5 +95,6 @@ class SelfAssessmentTest(unittest.TestCase):
# if we now assess as right, skip the REQUEST_HINT state # if we now assess as right, skip the REQUEST_HINT state
self.module.save_answer({'student_answer': 'answer 4'}, test_system) self.module.save_answer({'student_answer': 'answer 4'}, test_system)
self.module.save_assessment({'assessment': '1'}, test_system) responses['assessment'] = '1'
self.module.save_assessment(mock_query_dict, test_system)
self.assertEqual(self.module.state, self.module.DONE) self.assertEqual(self.module.state, self.module.DONE)
import dateutil
import dateutil.parser
import datetime
from timeparse import parse_timedelta
import logging
log = logging.getLogger(__name__)
class TimeInfo(object):
"""
This is a simple object that calculates and stores datetime information for an XModule
based on the due date string and the grace period string
So far it parses out three different pieces of time information:
self.display_due_date - the 'official' due date that gets displayed to students
self.grace_period - the length of the grace period
self.close_date - the real due date
"""
def __init__(self, display_due_date_string, grace_period_string):
if display_due_date_string is not None:
try:
self.display_due_date = dateutil.parser.parse(display_due_date_string)
except ValueError:
log.error("Could not parse due date {0}".format(display_due_date_string))
raise
else:
self.display_due_date = None
if grace_period_string is not None and self.display_due_date:
try:
self.grace_period = parse_timedelta(grace_period_string)
self.close_date = self.display_due_date + self.grace_period
except:
log.error("Error parsing the grace period {0}".format(grace_period_string))
raise
else:
self.grace_period = None
self.close_date = self.display_due_date
...@@ -411,7 +411,6 @@ class ResourceTemplates(object): ...@@ -411,7 +411,6 @@ class ResourceTemplates(object):
return templates return templates
class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates): class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates):
""" """
An XModuleDescriptor is a specification for an element of a course. This An XModuleDescriptor is a specification for an element of a course. This
...@@ -585,11 +584,11 @@ class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates): ...@@ -585,11 +584,11 @@ class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates):
def inherit_metadata(self, metadata): def inherit_metadata(self, metadata):
""" """
Updates this module with metadata inherited from a containing module. Updates this module with metadata inherited from a containing module.
Only metadata specified in self.inheritable_metadata will Only metadata specified in inheritable_metadata will
be inherited be inherited
""" """
# Set all inheritable metadata from kwargs that are # Set all inheritable metadata from kwargs that are
# in self.inheritable_metadata and aren't already set in metadata # in inheritable_metadata and aren't already set in metadata
for attr in self.inheritable_metadata: for attr in self.inheritable_metadata:
if attr not in self.metadata and attr in metadata: if attr not in self.metadata and attr in metadata:
self._inherited_metadata.add(attr) self._inherited_metadata.add(attr)
......
...@@ -128,8 +128,7 @@ class XmlDescriptor(XModuleDescriptor): ...@@ -128,8 +128,7 @@ class XmlDescriptor(XModuleDescriptor):
'graded': bool_map, 'graded': bool_map,
'hide_progress_tab': bool_map, 'hide_progress_tab': bool_map,
'allow_anonymous': bool_map, 'allow_anonymous': bool_map,
'allow_anonymous_to_peers': bool_map, 'allow_anonymous_to_peers': bool_map
'weight': int_map
} }
......
...@@ -39,6 +39,8 @@ if Backbone? ...@@ -39,6 +39,8 @@ if Backbone?
url = DiscussionUtil.urlFor 'threads' url = DiscussionUtil.urlFor 'threads'
when 'followed' when 'followed'
url = DiscussionUtil.urlFor 'followed_threads', options.user_id url = DiscussionUtil.urlFor 'followed_threads', options.user_id
if options['group_id']
data['group_id'] = options['group_id']
data['sort_key'] = sort_options.sort_key || 'date' data['sort_key'] = sort_options.sort_key || 'date'
data['sort_order'] = sort_options.sort_order || 'desc' data['sort_order'] = sort_options.sort_order || 'desc'
DiscussionUtil.safeAjax DiscussionUtil.safeAjax
......
...@@ -70,10 +70,21 @@ if Backbone? ...@@ -70,10 +70,21 @@ if Backbone?
DiscussionUtil.loadRoles(response.roles) DiscussionUtil.loadRoles(response.roles)
allow_anonymous = response.allow_anonymous allow_anonymous = response.allow_anonymous
allow_anonymous_to_peers = response.allow_anonymous_to_peers allow_anonymous_to_peers = response.allow_anonymous_to_peers
cohorts = response.cohorts
# $elem.html("Hide Discussion") # $elem.html("Hide Discussion")
@discussion = new Discussion() @discussion = new Discussion()
@discussion.reset(response.discussion_data, {silent: false}) @discussion.reset(response.discussion_data, {silent: false})
$discussion = $(Mustache.render $("script#_inline_discussion").html(), {'threads':response.discussion_data, 'discussionId': discussionId, 'allow_anonymous_to_peers': allow_anonymous_to_peers, 'allow_anonymous': allow_anonymous})
#use same discussion template but different thread templated
#determined in the coffeescript based on whether or not there's a
#group id
if response.is_cohorted
source = "script#_inline_discussion_cohorted"
else
source = "script#_inline_discussion"
$discussion = $(Mustache.render $(source).html(), {'threads':response.discussion_data, 'discussionId': discussionId, 'allow_anonymous_to_peers': allow_anonymous_to_peers, 'allow_anonymous': allow_anonymous, 'cohorts':cohorts})
if @$('section.discussion').length if @$('section.discussion').length
@$('section.discussion').replaceWith($discussion) @$('section.discussion').replaceWith($discussion)
else else
......
...@@ -9,6 +9,7 @@ if Backbone? ...@@ -9,6 +9,7 @@ if Backbone?
"click .browse-topic-drop-search-input": "ignoreClick" "click .browse-topic-drop-search-input": "ignoreClick"
"click .post-list .list-item a": "threadSelected" "click .post-list .list-item a": "threadSelected"
"click .post-list .more-pages a": "loadMorePages" "click .post-list .more-pages a": "loadMorePages"
"change .cohort-options": "chooseCohort"
'keyup .browse-topic-drop-search-input': DiscussionFilter.filterDrop 'keyup .browse-topic-drop-search-input': DiscussionFilter.filterDrop
initialize: -> initialize: ->
...@@ -128,10 +129,20 @@ if Backbone? ...@@ -128,10 +129,20 @@ if Backbone?
switch @mode switch @mode
when 'search' when 'search'
options.search_text = @current_search options.search_text = @current_search
if @group_id
options.group_id = @group_id
when 'followed' when 'followed'
options.user_id = window.user.id options.user_id = window.user.id
options.group_id = "all"
when 'commentables' when 'commentables'
options.commentable_ids = @discussionIds options.commentable_ids = @discussionIds
if @group_id
options.group_id = @group_id
when 'all'
if @group_id
options.group_id = @group_id
@collection.retrieveAnotherPage(@mode, options, {sort_key: @sortBy}) @collection.retrieveAnotherPage(@mode, options, {sort_key: @sortBy})
renderThread: (thread) => renderThread: (thread) =>
...@@ -263,13 +274,25 @@ if Backbone? ...@@ -263,13 +274,25 @@ if Backbone?
if discussionId == "#all" if discussionId == "#all"
@discussionIds = "" @discussionIds = ""
@$(".post-search-field").val("") @$(".post-search-field").val("")
@$('.cohort').show()
@retrieveAllThreads() @retrieveAllThreads()
else if discussionId == "#following" else if discussionId == "#following"
@retrieveFollowed(event) @retrieveFollowed(event)
@$('.cohort').hide()
else else
discussionIds = _.map item.find(".board-name[data-discussion_id]"), (board) -> $(board).data("discussion_id").id discussionIds = _.map item.find(".board-name[data-discussion_id]"), (board) -> $(board).data("discussion_id").id
@retrieveDiscussions(discussionIds)
if $(event.target).attr('cohorted') == "True"
@retrieveDiscussions(discussionIds, "function(){$('.cohort').show();}")
else
@retrieveDiscussions(discussionIds, "function(){$('.cohort').hide();}")
chooseCohort: (event) ->
@group_id = @$('.cohort-options :selected').val()
@collection.current_page = 0
@collection.reset()
@loadMorePages(event)
retrieveDiscussion: (discussion_id, callback=null) -> retrieveDiscussion: (discussion_id, callback=null) ->
url = DiscussionUtil.urlFor("retrieve_discussion", discussion_id) url = DiscussionUtil.urlFor("retrieve_discussion", discussion_id)
DiscussionUtil.safeAjax DiscussionUtil.safeAjax
......
...@@ -16,7 +16,10 @@ if Backbone? ...@@ -16,7 +16,10 @@ if Backbone?
@$delegateElement = @$local @$delegateElement = @$local
render: -> render: ->
@template = DiscussionUtil.getTemplate("_inline_thread") if @model.has('group_id')
@template = DiscussionUtil.getTemplate("_inline_thread_cohorted")
else
@template = DiscussionUtil.getTemplate("_inline_thread")
if not @model.has('abbreviatedBody') if not @model.has('abbreviatedBody')
@abbreviateBody() @abbreviateBody()
......
...@@ -25,6 +25,7 @@ if Backbone? ...@@ -25,6 +25,7 @@ if Backbone?
event.preventDefault() event.preventDefault()
title = @$(".new-post-title").val() title = @$(".new-post-title").val()
body = @$(".new-post-body").find(".wmd-input").val() body = @$(".new-post-body").find(".wmd-input").val()
group = @$(".new-post-group option:selected").attr("value")
# TODO tags: commenting out til we know what to do with them # TODO tags: commenting out til we know what to do with them
#tags = @$(".new-post-tags").val() #tags = @$(".new-post-tags").val()
...@@ -45,6 +46,7 @@ if Backbone? ...@@ -45,6 +46,7 @@ if Backbone?
data: data:
title: title title: title
body: body body: body
group_id: group
# TODO tags: commenting out til we know what to do with them # TODO tags: commenting out til we know what to do with them
#tags: tags #tags: tags
......
...@@ -14,8 +14,14 @@ if Backbone? ...@@ -14,8 +14,14 @@ if Backbone?
@setSelectedTopic() @setSelectedTopic()
DiscussionUtil.makeWmdEditor @$el, $.proxy(@$, @), "new-post-body" DiscussionUtil.makeWmdEditor @$el, $.proxy(@$, @), "new-post-body"
@$(".new-post-tags").tagsInput DiscussionUtil.tagsInputOptions() @$(".new-post-tags").tagsInput DiscussionUtil.tagsInputOptions()
if @$($(".topic_menu li a")[0]).attr('cohorted') != "True"
$('.choose-cohort').hide();
events: events:
"submit .new-post-form": "createPost" "submit .new-post-form": "createPost"
"click .topic_dropdown_button": "toggleTopicDropdown" "click .topic_dropdown_button": "toggleTopicDropdown"
...@@ -65,6 +71,11 @@ if Backbone? ...@@ -65,6 +71,11 @@ if Backbone?
@topicText = @getFullTopicName($target) @topicText = @getFullTopicName($target)
@topicId = $target.data('discussion_id') @topicId = $target.data('discussion_id')
@setSelectedTopic() @setSelectedTopic()
if $target.attr('cohorted') == "True"
$('.choose-cohort').show();
else
$('.choose-cohort').hide();
setSelectedTopic: -> setSelectedTopic: ->
@dropdownButton.html(@fitName(@topicText) + ' <span class="drop-arrow">▾</span>') @dropdownButton.html(@fitName(@topicText) + ' <span class="drop-arrow">▾</span>')
...@@ -116,6 +127,7 @@ if Backbone? ...@@ -116,6 +127,7 @@ if Backbone?
title = @$(".new-post-title").val() title = @$(".new-post-title").val()
body = @$(".new-post-body").find(".wmd-input").val() body = @$(".new-post-body").find(".wmd-input").val()
tags = @$(".new-post-tags").val() tags = @$(".new-post-tags").val()
group = @$(".new-post-group option:selected").attr("value")
anonymous = false || @$("input.discussion-anonymous").is(":checked") anonymous = false || @$("input.discussion-anonymous").is(":checked")
anonymous_to_peers = false || @$("input.discussion-anonymous-to-peers").is(":checked") anonymous_to_peers = false || @$("input.discussion-anonymous-to-peers").is(":checked")
...@@ -137,6 +149,7 @@ if Backbone? ...@@ -137,6 +149,7 @@ if Backbone?
anonymous: anonymous anonymous: anonymous
anonymous_to_peers: anonymous_to_peers anonymous_to_peers: anonymous_to_peers
auto_subscribe: follow auto_subscribe: follow
group_id: group
error: DiscussionUtil.formErrorHandler(@$(".new-post-form-errors")) error: DiscussionUtil.formErrorHandler(@$(".new-post-form-errors"))
success: (response, textStatus) => success: (response, textStatus) =>
# TODO: Move this out of the callback, this makes it feel sluggish # TODO: Move this out of the callback, this makes it feel sluggish
......
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns="http://www.w3.org/2000/svg"
width="40"
height="40">
<path
d="M 1.5006714,23.536225 6.8925879,18.994244 14.585721,26.037937 34.019683,4.5410479 38.499329,9.2235032 14.585721,35.458952 z"
id="path4"
style="fill:#ffff00;fill-opacity:1;stroke:#000000;stroke-width:1.25402856;stroke-opacity:1" />
</svg>
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns="http://www.w3.org/2000/svg"
height="40"
width="40">
<rect
style="fill:#ffff00;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
width="33.76017"
height="33.76017"
x="3.119915"
y="3.119915" />
<path
d="m 20.677967,8.54499 c -7.342801,0 -13.295293,4.954293 -13.295293,11.065751 0,2.088793 0.3647173,3.484376 1.575539,5.150563 L 6.0267418,31.45501 13.560595,29.011117 c 2.221262,1.387962 4.125932,1.665377 7.117372,1.665377 7.3428,0 13.295291,-4.954295 13.295291,-11.065753 0,-6.111458 -5.952491,-11.065751 -13.295291,-11.065751 z"
style="fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:0.93031836;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"/>
</svg>
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns="http://www.w3.org/2000/svg"
width="40"
height="40">
<g
transform="translate(0,-60)"
id="layer1">
<rect
width="36.460953"
height="34.805603"
x="1.7695236"
y="62.597198"
style="fill:#ffff00;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1.30826771;stroke-opacity:1" />
<g
transform="matrix(0.88763677,0,0,0.88763677,2.2472646,8.9890584)">
<path
d="M 20,64.526342 C 11.454135,64.526342 4.5263421,71.454135 4.5263421,80 4.5263421,88.545865 11.454135,95.473658 20,95.473658 28.545865,95.473658 35.473658,88.545865 35.473658,80 35.473658,71.454135 28.545865,64.526342 20,64.526342 z m -0.408738,9.488564 c 3.527079,0 6.393832,2.84061 6.393832,6.335441 0,3.494831 -2.866753,6.335441 -6.393832,6.335441 -3.527079,0 -6.393832,-2.84061 -6.393832,-6.335441 0,-3.494831 2.866753,-6.335441 6.393832,-6.335441 z"
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:1.02768445;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" />
<path
d="m 7.2335209,71.819938 4.9702591,4.161823 c -1.679956,2.581606 -1.443939,6.069592 0.159325,8.677725 l -5.1263071,3.424463 c 0.67516,1.231452 3.0166401,3.547686 4.2331971,4.194757 l 3.907728,-4.567277 c 2.541952,1.45975 5.730694,1.392161 8.438683,-0.12614 l 3.469517,6.108336 c 1.129779,-0.44367 4.742234,-3.449633 5.416358,-5.003859 l -5.46204,-4.415541 c 1.44319,-2.424098 1.651175,-5.267515 0.557303,-7.748623 l 5.903195,-3.833951 C 33.14257,71.704996 30.616217,69.018606 29.02952,67.99296 l -4.118813,4.981678 C 22.411934,71.205099 18.900853,70.937534 16.041319,72.32916 l -3.595408,-5.322091 c -1.345962,0.579488 -4.1293881,2.921233 -5.2123901,4.812869 z m 8.1010311,3.426672 c 2.75284,-2.446266 6.769149,-2.144694 9.048998,0.420874 2.279848,2.56557 2.113919,6.596919 -0.638924,9.043185 -2.752841,2.446267 -6.775754,2.13726 -9.055604,-0.428308 -2.279851,-2.565568 -2.107313,-6.589485 0.64553,-9.035751 z"
style="fill:#000000;fill-opacity:1;stroke:none" />
</g>
</g>
</svg>
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns="http://www.w3.org/2000/svg"
width="64"
height="64">
<path
d="M 32.003143,1.4044602 57.432701,62.632577 6.5672991,62.627924 z"
style="fill:#ffff00;fill-opacity:0.94117647;fill-rule:nonzero;stroke:#000000;stroke-width:1.00493038;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" />
</svg>
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns="http://www.w3.org/2000/svg"
width="64"
height="64">
<path
d="M 25.470843,9.4933766 C 25.30219,12.141818 30.139101,14.445969 34.704831,13.529144 40.62635,12.541995 41.398833,7.3856498 35.97505,5.777863 31.400921,4.1549155 25.157674,6.5445892 25.470843,9.4933766 z M 4.5246282,17.652051 C 4.068249,11.832873 9.2742983,5.9270407 18.437379,3.0977088 29.751911,-0.87185184 45.495663,1.4008022 53.603953,7.1104009 c 9.275765,6.1889221 7.158128,16.2079421 -3.171076,21.5939521 -1.784316,1.635815 -6.380222,1.21421 -7.068351,3.186186 -1.04003,0.972427 -1.288046,2.050158 -1.232864,3.168203 1.015111,2.000108 -3.831548,1.633216 -3.270553,3.759574 0.589477,5.264544 -0.179276,10.53738 -0.362842,15.806257 -0.492006,2.184998 1.163456,4.574232 -0.734888,6.610642 -2.482919,2.325184 -7.30604,2.189143 -9.193497,-0.274767 -2.733688,-1.740626 -8.254447,-3.615254 -6.104247,-6.339626 3.468112,-1.708686 -2.116197,-3.449897 0.431242,-5.080274 5.058402,-1.39256 -2.393215,-2.304318 -0.146889,-4.334645 3.069198,-0.977415 2.056986,-2.518352 -0.219121,-3.540397 1.876567,-1.807151 1.484149,-4.868919 -2.565455,-5.942205 0.150866,-1.805474 2.905737,-4.136876 -1.679967,-5.20493 C 10.260902,27.882167 4.6872697,22.95045 4.5245945,17.652051 z"
id="path604"
style="fill:#ffff00;fill-opacity:1;stroke:#000000;stroke-width:1.72665179;stroke-opacity:1" />
</svg>
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns="http://www.w3.org/2000/svg"
width="64"
height="64">
<path
d="M 32.003143,10.913072 57.432701,53.086929 6.567299,53.083723 z"
id="path2985"
style="fill:#ffff00;fill-opacity:0.94117647;fill-rule:nonzero;stroke:#000000;stroke-width:0.83403099;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" />
</svg>
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns="http://www.w3.org/2000/svg"
width="40"
height="40">
<rect
width="36.075428"
height="31.096582"
x="1.962286"
y="4.4517088"
id="rect4"
style="fill:#ffff00;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1.23004246;stroke-opacity:1" />
<rect
width="27.96859"
height="1.5012145"
x="6.0157046"
y="10.285"
id="rect6"
style="fill:#000000;fill-opacity:1;stroke:none" />
<rect
width="27.96859"
height="0.85783684"
x="6.0157056"
y="23.21689"
id="rect8"
style="fill:#000000;fill-opacity:1;stroke:none" />
<rect
width="27.96859"
height="0.85783684"
x="5.8130345"
y="28.964394"
id="rect10"
style="fill:#000000;fill-opacity:1;stroke:none" />
<rect
width="27.96859"
height="0.85783684"
x="6.0157046"
y="17.426493"
id="rect12"
style="fill:#000000;fill-opacity:1;stroke:none" />
</svg>
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns="http://www.w3.org/2000/svg"
width="40"
height="40">
<rect
width="33.76017"
height="33.76017"
x="3.119915"
y="3.119915"
style="fill:#ffff00;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" />
<path
d="m 17.692678,34.50206 0,-16.182224 c -1.930515,-0.103225 -3.455824,-0.730383 -4.57593,-1.881473 -1.12011,-1.151067 -1.680164,-2.619596 -1.680164,-4.405591 0,-1.992435 0.621995,-3.5796849 1.865988,-4.7617553 1.243989,-1.1820288 3.06352,-1.7730536 5.458598,-1.7730764 l 9.802246,0 0,2.6789711 -2.229895,0 0,26.3251486 -2.632515,0 0,-26.3251486 -3.45324,0 0,26.3251486 z"
style="font-size:29.42051125px;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;text-align:start;line-height:125%;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:1.07795751;stroke-opacity:1;font-family:Arial;-inkscape-font-specification:Arial" />
</svg>
...@@ -144,6 +144,7 @@ var CohortManager = (function ($) { ...@@ -144,6 +144,7 @@ var CohortManager = (function ($) {
$(".remove", tr).html('<a href="#">remove</a>') $(".remove", tr).html('<a href="#">remove</a>')
.click(function() { .click(function() {
remove_user_from_cohort(item.username, current_cohort_id, tr); remove_user_from_cohort(item.username, current_cohort_id, tr);
return false;
}); });
detail_users.append(tr); detail_users.append(tr);
...@@ -217,6 +218,7 @@ var CohortManager = (function ($) { ...@@ -217,6 +218,7 @@ var CohortManager = (function ($) {
show_cohorts_button.click(function() { show_cohorts_button.click(function() {
state = state_summary; state = state_summary;
render(); render();
return false;
}); });
add_cohort_input.change(function() { add_cohort_input.change(function() {
...@@ -231,12 +233,14 @@ var CohortManager = (function ($) { ...@@ -231,12 +233,14 @@ var CohortManager = (function ($) {
var add_url = url + '/add'; var add_url = url + '/add';
data = {'name': add_cohort_input.val()} data = {'name': add_cohort_input.val()}
$.post(add_url, data).done(added_cohort); $.post(add_url, data).done(added_cohort);
return false;
}); });
add_members_button.click(function() { add_members_button.click(function() {
var add_url = detail_url + '/add'; var add_url = detail_url + '/add';
data = {'users': users_area.val()} data = {'users': users_area.val()}
$.post(add_url, data).done(added_users); $.post(add_url, data).done(added_users);
return false;
}); });
......
# Copyright 2012 Mozilla Foundation
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# Main toolbar buttons (tooltips and alt text for images)
previous.title=الصفحة السابقة
previous_label=السابق
next.title=الصفحة التاليه
next_label=التالي
# LOCALIZATION NOTE (page_label, page_of):
# These strings are concatenated to form the "Page: X of Y" string.
# Do not translate "{{pageCount}}", it will be substituted with a number
# representing the total number of pages.
page_label=الصفحة:
page_of=من {{pageCount}}
zoom_out.title=تصغير
zoom_out_label=تصغير
zoom_in.title=تكبير
zoom_in_label=تكبير
zoom.title=التكبير
print.title=طباعة
print_label=طباعة
fullscreen.title=ملء الشاشة
fullscreen_label=ملء الشاشة
open_file.title=فتح الملف
open_file_label=فتح
download.title=تحميل
download_label=تحميل
bookmark.title=المشهد الحالي (نسخ أو فتح في نافذة جديدة)
bookmark_label=المشهد الحالي
# Tooltips and alt text for side panel toolbar buttons
# (the _label strings are alt text for the buttons, the .title strings are
# tooltips)
toggle_slider.title=تبديل الزلاق
toggle_slider_label=تبديل الزلاق
outline.title=إظهار ملخص المستند
outline_label=ملخص المستند
thumbs.title=إظهار الصور المصغرة
thumbs_label=الصور المصغرة
findbar.title=البحث في المستند
findbar_label=بحث
# Document outline messages
no_outline=لا يوجد ملخص
# Thumbnails panel item (tooltip and alt text for images)
# LOCALIZATION NOTE (thumb_page_title): "{{page}}" will be replaced by the page
# number.
thumb_page_title=الصفحة {{page}}
# LOCALIZATION NOTE (thumb_page_canvas): "{{page}}" will be replaced by the page
# number.
thumb_page_canvas=صورة مصغرة من الصفحة {{page}}
# Context menu
page_rotate_cw.label=تدوير مع عقارب الساعة
page_rotate_ccw.label=تدوير عكس عقارب الساعة
# Find panel button title and messages
find=بحث
find_terms_not_found=(لا يوجد)
# Error panel labels
error_more_info=مزيد من المعلومات
error_less_info=معلومات أقل
error_close=إغلاق
# LOCALIZATION NOTE (error_build): "{{build}}" will be replaced by the PDF.JS
# build ID.
error_build=بناء PDF.JS: {{build}}
# LOCALIZATION NOTE (error_message): "{{message}}" will be replaced by an
# english string describing the error.
error_message=رسالة: {{message}}
# LOCALIZATION NOTE (error_stack): "{{stack}}" will be replaced with a stack
# trace.
error_stack=المكدس: {{stack}}
# LOCALIZATION NOTE (error_file): "{{file}}" will be replaced with a filename
error_file=الملف: {{file}}
# LOCALIZATION NOTE (error_line): "{{line}}" will be replaced with a line number
error_line=السطر: {{line}}
rendering_error=حدث خطأ اثناء رسم الصفحة.
# Predefined zoom values
page_scale_width=عرض الصفحة
page_scale_fit=تناسب الصفحة
page_scale_auto=تقريب تلقائي
page_scale_actual=الحجم الحقيقي
# Loading indicator messages
loading_error_indicator=خطأ
loading_error=حدث خطأ أثناء تحميل وثيقه الـPDF
# LOCALIZATION NOTE (text_annotation_type): This is used as a tooltip.
# "{{type}}" will be replaced with an annotation type from a list defined in
# the PDF spec (32000-1:2008 Table 169 – Annotation types).
# Some common types are e.g.: "Check", "Text", "Comment", "Note"
text_annotation_type=[ملاحظة {{type}}]
request_password=الـPDF محمي بكلمة مرور:
printing_not_supported=تحذير: الطباعة ليست مدعومة كليًا في هذا المتصفح.
# Copyright 2012 Mozilla Foundation
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# Main toolbar buttons (tooltips and alt text for images)
previous.title=Pàgina anterior
previous_label=Anterior
next.title=Pàgina següent
next_label=Següent
# LOCALIZATION NOTE (page_label, page_of):
# These strings are concatenated to form the "Page: X of Y" string.
# Do not translate "{{pageCount}}", it will be substituted with a number
# representing the total number of pages.
page_label=Pàgina:
page_of=de {{pageCount}}
zoom_out.title=Reduir
zoom_out_label=Reduir
zoom_in.title=Ampliar
zoom_in_label=Ampliar
zoom.title=Ampliació
print.title=Imprimir
print_label=Imprimir
fullscreen.title=Pantalla completa
fullscreen_label=Pantalla completa
presentation_mode.title=Canviar a mode de Presentació
presentation_mode_label=Mode de Presentació
open_file.title=Obrir arxiu
open_file_label=Obrir
download.title=Descarregar
download_label=Descarregar
bookmark.title=Vista actual (copiï o obri en una finestra nova)
bookmark_label=Vista actual
# Tooltips and alt text for side panel toolbar buttons
# (the _label strings are alt text for the buttons, the .title strings are
# tooltips)
toggle_slider.title=Alternar lliscador
toggle_slider_label=Alternar lliscador
outline.title=Mostrar esquema del document
outline_label=Esquema del document
thumbs.title=Mostrar miniatures
thumbs_label=Miniatures
findbar.title=Cercar en el document
findbar_label=Cercar
# Document outline messages
no_outline=No hi ha cap esquema disponible
# Thumbnails panel item (tooltip and alt text for images)
# LOCALIZATION NOTE (thumb_page_title): "{{page}}" will be replaced by the page
# number.
thumb_page_title=Pàgina {{page}}
# LOCALIZATION NOTE (thumb_page_canvas): "{{page}}" will be replaced by the page
# number.
thumb_page_canvas=Miniatura de la pàgina {{page}}
# Find panel button title and messages
find=Cercar
find_terms_not_found=(No trobat)
# Context menu
first_page.label=Primera pàgina
last_page.label=Darrera pàgina
page_rotate_cw.label=Rotar sentit horari
page_rotate_ccw.label=Rotar sentit anti-horari
# Find panel button title and messages
find_label=Cerca:
find_previous.title=Trobar ocurrència anterior
find_previous_label=Previ
find_next.title=Trobar ocurrència posterior
find_next_label=Següent
find_highlight=Contrastar tot
find_match_case_label=Majúscules i minúscules
find_wrapped_to_bottom=Part superior assolida, continu a la part inferior
find_wrapped_to_top=Final de pàgina finalitzada, continu a la part superior
find_not_found=Frase no trobada
# Error panel labels
error_more_info=Més informació
error_less_info=Menys informació
error_close=Tancar
# LOCALIZATION NOTE (error_build): "{{build}}" will be replaced by the PDF.JS
# build ID.
error_build=Compilació de PDF.JS: {{build}}
# LOCALIZATION NOTE (error_message): "{{message}}" will be replaced by an
# english string describing the error.
error_message=Missatge: {{message}}
# LOCALIZATION NOTE (error_stack): "{{stack}}" will be replaced with a stack
# trace.
error_stack=Pila: {{stack}}
# LOCALIZATION NOTE (error_file): "{{file}}" will be replaced with a filename
error_file=Arxiu: {{file}}
# LOCALIZATION NOTE (error_line): "{{line}}" will be replaced with a line number
error_line=Línia: {{line}}
rendering_error=Ha ocurregut un error mentre es renderitzava la pàgina.
# Predefined zoom values
page_scale_width=Ample de pàgina
page_scale_fit=Ajustar a la pàgina
page_scale_auto=Ampliació automàtica
page_scale_actual=Tamany real
# Loading indicator messages
loading_error_indicator=Error
loading_error=Ha ocorregut un error mentres es carregava el PDF.
invalid_file_error=Invàlid o fitxer PDF corrupte.
# LOCALIZATION NOTE (text_annotation_type): This is used as a tooltip.
# "{{type}}" will be replaced with an annotation type from a list defined in
# the PDF spec (32000-1:2008 Table 169 – Annotation types).
# Some common types are e.g.: "Check", "Text", "Comment", "Note"
text_annotation_type=[Anotació {{type}}]
request_password=El PDF està protegit amb una contrasenya:
printing_not_supported=Avís: La impressió no és compatible totalment en aquest navegador.
# Copyright 2012 Mozilla Foundation
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
bookmark.title=Aktuální zobrazení(zkopírovat nebo otevřít v novém okně)
previous.title=Předchozí stránka
next.title=Další stránka
print.title=Tisk
download.title=Stáhnout
zoom_out.title=Zmenšit
zoom_in.title=Zvětšit
error_more_info=Více informací
error_less_info=Méně informací
error_close=Zavřít
error_build=PDF.JS Build: {{build}}
error_message=Zpráva:{{message}}
error_stack=Stack:{{stack}}
error_file=Soubor:{{file}}
error_line=Řádek:{{line}}
page_scale_width=Šířka stránky
page_scale_fit=Stránka
page_scale_auto=Automatické přibližení
page_scale_actual=Skutečná velikost
toggle_slider.title=Přepnout posuvník
thumbs.title=Zobrazit náhledy
outline.title=Zobrazit osnovu dokumentu
loading=Načítám... {{percent}}%
loading_error_indicator=Chyba
loading_error=Došlo k chybě při načítání PDF.
rendering_error=Došlo k chybě při vykreslování stránky.
page_label=Stránka:
page_of=z{{pageCount}}
no_outline=Žádné osnovy k dispozici
open_file.title=Otevřít soubor
text_annotation_type=[{{type}}Anotace]
toggle_slider_label=Přepnout posuvník
thumbs_label=Náhledy
outline_label=Přehled dokumentu
bookmark_label=Aktuální zobrazení
previous_label=Předchozí
next_label=Další
print_label=Tisk
download_label=Stáhnout
zoom_out_label=Zmenšit
zoom_in_label=Přiblížit
zoom.title=Zvětšit
thumb_page_title=Stránka{{page}}
thumb_page_canvas=Náhled stránky {{page}}
request_password=PDF je chráněn heslem:
# Copyright 2012 Mozilla Foundation
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# Værktøjslinje knapper (tooltups og billedtekster)
previous.title=Forrige
previous_label=Forrige
next.title=Næste
next_label=Næste
# Oversættelsesnote:
# Disse tekststrenge bliver sammensat i formen "Side: X af Y"
# Oversæt ikke "{{pageCount}}", det er en variabel og vil blive erstattet
# med det egentlig antal sider i PDF filen
page_label=Side:
page_of=af {{pageCount}}
zoom_out.title=Zoom ud
zoom_out_label=Zoom ud
zoom_in.title=Zoom ind
zoom_in_label=Zoom ind
zoom.title=Zoom
print_label=Udskriv
print.title=Udskriv
fullscreen.title=Fuldskærm
fullscreen_label=Fuldskærm
open_file.title=Åbn fil
open_file_label=Åbn
download.title=Hent
download_label=Hent
bookmark.title=Aktuel visning (kopier eller åbn i et nyt vindue)
bookmark_label=Aktuel visning
# Tooltips of alternativ billedtekst til sidepanelet
# (_label strengene er den alternative billedtekst, mens .title
# strengene er tooltips
toggle_slider.title=Skift slider
toggle_slider_label=Skift slider
outline.title=Vis dokumentoversigt
outline_label=Dokumentoversigt
thumbs.title=Vis thumbnails
thumbs_label=Thumbnails
findbar.title=Søg i dokumentet
findbar_label=Søg
# Dokumentoversigtsbeskeder
no_outline=Ingen dokumentoversigt tilgængelig
# Thumbnails panelet (tooltips og alt. billedtekst)
# Oversættelsesnote: "{{page}}" vil blive erstattet af det
# egentlige sidetal
thumb_page_title=Side {{page}}
# Oversættelsesnote: "{{page}}" vil blive erstattet af det
# egentlige sidetal
thumb_page_canvas=Thumbnail af side {{page}}
# Søgepanelet
find=Søg
find_terms_not_found=(Ikke fundet)
# Fejlpanel
error_more_info=Mere information
error_less_info=Mindre information
error_close=Luk
# Oversættelsesnote: "{{build}}" vil blive erstattet af PDF.JS build nummer
#
error_build=PDF.JS Build: {{build}}
# Oversættelsesnote: "{{message}}" vil blive erstattet af en (engelsk) fejlbesked
#
error_message=Besked: {{message}}
# Oversættelsesnote: "{{stack}}" vil blive erstattet af et stack trace
#
error_stack=Stak: {{stack}}
# Oversættelsesnote: "{{file}}" vil blive erstattet af et filnavn
error_file=Fil: {{file}}
# Oversættelsesnote: "{{line}}" vil blive erstattet af et linjetal
error_line=Linje: {{line}}
rendering_error=Der skete en fejl under gengivelsen af PDF-filen
# Prædefinerede zoom værdier
page_scale_width=Sidebredde
page_scale_fit=Helside
page_scale_auto=Automatisk zoom
page_scale_actual=Faktisk størrelse
# Indlæsningsindikator (load ikon)
loading_error_indicator=Fejl
loading_error=Der skete en fejl under indlæsningen af PDF-filen
# Oversættelsesnote: Dette vil blive brugt som et tooltip
# "{{type}}" vil blive ersattet af en kommentar type fra en liste
# defineret i PDF specifikationen (32000-1:2008 Table 169 – Annotation types).
# Nogle almindelige typer er f.eks.: "Check", "Text", "Comment" og "Note"
text_annotation_type=[{{type}} Kommentar]
request_password=PDF filen er beskyttet med et kodeord:
printing_not_supported=Advarsel: Denne browser er ikke fuldt understøttet ved udskrift
# Copyright 2012 Mozilla Foundation
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# Main toolbar buttons (tooltips and alt text for images)
previous.title=Eine Seite zurück
previous_label=Zurück
next.title=Eine Seite vor
next_label=Vor
# LOCALIZATION NOTE (page_label, page_of):
# These strings are concatenated to form the "Page: X of Y" string.
# Do not translate "{{pageCount}}", it will be substituted with a number
# representing the total number of pages.
page_label=Seite:
page_of=von {{pageCount}}
zoom_out.title=Verkleinern
zoom_out_label=Verkleinern
zoom_in.title=Vergrößern
zoom_in_label=Vergrößern
zoom.title=Zoom
print.title=Drucken
print_label=Drucken
presentation_mode.title=Zum Präsentationsmodus wechseln
presentation_mode_label=Bildschirmpräsentation
open_file.title=Datei öffnen
open_file_label=Öffnen
download.title=Herunterladen
download_label=Herunterladen
bookmark.title=Aktuelle Ansicht (Kopieren oder in einem neuen Fenster öffnen)
bookmark_label=Aktuelle Ansicht
# Tooltips and alt text for side panel toolbar buttons
# (the _label strings are alt text for the buttons, the .title strings are
# tooltips)
toggle_slider.title=Seitenleiste anzeigen
toggle_slider_label=Seitenleiste
outline.title=Zeige Inhaltsverzeichnis
outline_label=Inhaltsverzeichnis
thumbs.title=Zeige Vorschaubilder
thumbs_label=Vorschaubilder
findbar.title=Im Dokument suchen
findbar_label=Suchen
# Document outline messages
no_outline=Kein Inhaltsverzeichnis verfügbar
# Thumbnails panel item (tooltip and alt text for images)
# LOCALIZATION NOTE (thumb_page_title): "{{page}}" will be replaced by the page
# number.
thumb_page_title=Seite {{page}}
# LOCALIZATION NOTE (thumb_page_canvas): "{{page}}" will be replaced by the page
# number.
thumb_page_canvas=Vorschau von Seite {{page}}
# Context menu
first_page.label=Erste Seite
last_page.label=Letzte Seite
page_rotate_cw.label=Im Uhrzeigersinn drehen
page_rotate_ccw.label=Entgegen dem Uhrzeigersinn drehen
# Find panel button title and messages
find_label=Suchen:
find_previous.title=Das vorherige Auftreten des Ausdrucks suchen
find_previous_label=Aufwärts
find_next.title=Das nächste Auftreten des Ausdrucks suchen
find_next_label=Abwärts
find_highlight=Hervorheben
find_match_case_label=Groß-/Kleinschreibung
find_reached_top=Der Anfang des Dokuments wurde erreicht, Suche am Ende des Dokuments fortgesetzt
find_reached_bottom=Das Ende des Dokuments wurde erreicht, Suche am Anfang des Dokuments fortgesetzt
find_not_found=Ausdruck nicht gefunden
# Error panel labels
error_more_info=Mehr Info
error_less_info=Weniger Info
error_close=Schließen
# LOCALIZATION NOTE (error_build): "{{build}}" will be replaced by the PDF.JS
# build ID.
error_build=PDF.JS Build: {{build}}
# LOCALIZATION NOTE (error_message): "{{message}}" will be replaced by an
# english string describing the error.
error_message=Nachricht: {{message}}
# LOCALIZATION NOTE (error_stack): "{{stack}}" will be replaced with a stack
# trace.
error_stack=Stack: {{stack}}
# LOCALIZATION NOTE (error_file): "{{file}}" will be replaced with a filename
error_file=Datei: {{file}}
# LOCALIZATION NOTE (error_line): "{{line}}" will be replaced with a line number
error_line=Zeile: {{line}}
rendering_error=Das PDF konnte nicht angezeigt werden.
# Predefined zoom values
page_scale_width=Seitenbreite
page_scale_fit=Ganze Seite
page_scale_auto=Automatisch
page_scale_actual=Originalgröße
# Loading indicator messages
loading_error_indicator=Fehler
loading_error=Das PDF konnte nicht geladen werden.
invalid_file_error=Ungültige oder beschädigte PDF-Datei.
# LOCALIZATION NOTE (text_annotation_type): This is used as a tooltip.
# "{{type}}" will be replaced with an annotation type from a list defined in
# the PDF spec (32000-1:2008 Table 169 – Annotation types).
# Some common types are e.g.: "Check", "Text", "Comment", "Note"
text_annotation_type=[{{type}} Annotation]
request_password=Das PDF ist passwortgeschützt:
printing_not_supported=Warnung: Drucken wird durch diesen Browser nicht vollständig unterstützt.
web_fonts_disabled=Webfonts sind deaktiviert: Eingebundene PDF-Schriftarten können nicht verwendet werden.
# Copyright 2012 Mozilla Foundation
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# Main toolbar buttons (tooltips and alt text for images)
previous.title=Previous Page
previous_label=Previous
next.title=Next Page
next_label=Next
# LOCALIZATION NOTE (page_label, page_of):
# These strings are concatenated to form the "Page: X of Y" string.
# Do not translate "{{pageCount}}", it will be substituted with a number
# representing the total number of pages.
page_label=Page:
page_of=of {{pageCount}}
zoom_out.title=Zoom Out
zoom_out_label=Zoom Out
zoom_in.title=Zoom In
zoom_in_label=Zoom In
zoom.title=Zoom
print.title=Print
print_label=Print
presentation_mode.title=Switch to Presentation Mode
presentation_mode_label=Presentation Mode
open_file.title=Open File
open_file_label=Open
download.title=Download
download_label=Download
bookmark.title=Current view (copy or open in new window)
bookmark_label=Current View
# Tooltips and alt text for side panel toolbar buttons
# (the _label strings are alt text for the buttons, the .title strings are
# tooltips)
toggle_sidebar.title=Toggle Sidebar
toggle_sidebar_label=Toggle Sidebar
outline.title=Show Document Outline
outline_label=Document Outline
thumbs.title=Show Thumbnails
thumbs_label=Thumbnails
findbar.title=Find in Document
findbar_label=Find
# Document outline messages
no_outline=No Outline Available
# Thumbnails panel item (tooltip and alt text for images)
# LOCALIZATION NOTE (thumb_page_title): "{{page}}" will be replaced by the page
# number.
thumb_page_title=Page {{page}}
# LOCALIZATION NOTE (thumb_page_canvas): "{{page}}" will be replaced by the page
# number.
thumb_page_canvas=Thumbnail of Page {{page}}
# Context menu
first_page.label=Go to First Page
last_page.label=Go to Last Page
page_rotate_cw.label=Rotate Clockwise
page_rotate_ccw.label=Rotate Counterclockwise
# Find panel button title and messages
find_label=Find:
find_previous.title=Find the previous occurrence of the phrase
find_previous_label=Previous
find_next.title=Find the next occurrence of the phrase
find_next_label=Next
find_highlight=Highlight all
find_match_case_label=Match case
find_reached_top=Reached top of document, continued from bottom
find_reached_bottom=Reached end of document, continued from top
find_not_found=Phrase not found
# Error panel labels
error_more_info=More Information
error_less_info=Less Information
error_close=Close
# LOCALIZATION NOTE (error_version_info): "{{version}}" and "{{build}}" will be
# replaced by the PDF.JS version and build ID.
error_version_info=PDF.js v{{version}} (build: {{build}})
# LOCALIZATION NOTE (error_message): "{{message}}" will be replaced by an
# english string describing the error.
error_message=Message: {{message}}
# LOCALIZATION NOTE (error_stack): "{{stack}}" will be replaced with a stack
# trace.
error_stack=Stack: {{stack}}
# LOCALIZATION NOTE (error_file): "{{file}}" will be replaced with a filename
error_file=File: {{file}}
# LOCALIZATION NOTE (error_line): "{{line}}" will be replaced with a line number
error_line=Line: {{line}}
rendering_error=An error occurred while rendering the page.
# Predefined zoom values
page_scale_width=Page Width
page_scale_fit=Page Fit
page_scale_auto=Automatic Zoom
page_scale_actual=Actual Size
# Loading indicator messages
loading_error_indicator=Error
loading_error=An error occurred while loading the PDF.
invalid_file_error=Invalid or corrupted PDF file.
missing_file_error=Missing PDF file.
# LOCALIZATION NOTE (text_annotation_type): This is used as a tooltip.
# "{{type}}" will be replaced with an annotation type from a list defined in
# the PDF spec (32000-1:2008 Table 169 – Annotation types).
# Some common types are e.g.: "Check", "Text", "Comment", "Note"
text_annotation_type=[{{type}} Annotation]
request_password=PDF is protected by a password:
printing_not_supported=Warning: Printing is not fully supported by this browser.
web_fonts_disabled=Web fonts are disabled: unable to use embedded PDF fonts.
# Copyright 2012 Mozilla Foundation
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# Main toolbar buttons (tooltips and alt text for images)
previous.title=Página anterior
previous_label=Anterior
next.title=Página siguiente
next_label=Siguiente
# LOCALIZATION NOTE (page_label, page_of):
# These strings are concatenated to form the "Page: X of Y" string.
# Do not translate "{{pageCount}}", it will be substituted with a number
# representing the total number of pages.
page_label=Página:
page_of=of {{pageCount}}
zoom_out.title=Reducir
zoom_out_label=Reducir
zoom_in.title=Aumentar
zoom_in_label=Aumentar
zoom.title=Tamaño
print.title=Imprimir
print_label=Imprimir
presentation_mode.title=Cambiar al modo de presentación
presentation_mode_label=Modo de presentación
open_file.title=Abrir archivo
open_file_label=Abrir
download.title=Descargar
download_label=Descargar
bookmark.title=Vista actual (copiar o abrir en una nueva ventana)
bookmark_label=Vista actual
# Tooltips and alt text for side panel toolbar buttons
# (the _label strings are alt text for the buttons, the .title strings are
# tooltips)
toggle_sidebar.title=Activar barra lateral
toggle_sidebar_label=Activar barra lateral
outline.title=Mostrar el esquema del documento
outline_label=Esquema del documento
thumbs.title=Mostrar miniaturas
thumbs_label=Miniaturas
findbar.title=Buscar en el documento
findbar_label=Buscar
# Document outline messages
no_outline=No hay esquema disponible
# Thumbnails panel item (tooltip and alt text for images)
# LOCALIZATION NOTE (thumb_page_title): "{{page}}" will be replaced by the page
# number.
thumb_page_title=Página {{page}}
# LOCALIZATION NOTE (thumb_page_canvas): "{{page}}" will be replaced by the page
# number.
thumb_page_canvas=Miniatura o página {{page}}
# Context menu
first_page.label=Ir a la primera página
last_page.label=Ir a la última página
page_rotate_cw.label=Girar hacia la derecha
page_rotate_ccw.label=Girar hacia la izquierda
# Find panel button title and messages
find_label=Buscar:
find_previous.title=Ir a la anterior frase encontrada
find_previous_label=Anterior
find_next.title=Ir a la siguiente frase encontrada
find_next_label=Siguiente
find_highlight=Marcar todo
find_match_case_label=Coincidir con mayúsculas y minúsculas
find_reached_top=Inicio del documento, se continúa desde el final
find_reached_bottom=Final del documento, se continúa desde el inicio
find_not_found=No se encontró la frase
# Error panel labels
error_more_info=Más información
error_less_info=Menos información
error_close=Cerrar
# LOCALIZATION NOTE (error_version_info): "{{version}}" and "{{build}}" will be
# replaced by the PDF.JS version and build ID.
error_version_info=PDF.js v{{version}} (compilación: {{build}})
# LOCALIZATION NOTE (error_message): "{{message}}" will be replaced by an
# english string describing the error.
error_message=Mensaje: {{message}}
# LOCALIZATION NOTE (error_stack): "{{stack}}" will be replaced with a stack
# trace.
error_stack=Pila: {{stack}}
# LOCALIZATION NOTE (error_file): "{{file}}" will be replaced with a filename
error_file=Archivo: {{file}}
# LOCALIZATION NOTE (error_line): "{{line}}" will be replaced with a line number
error_line=Línea: {{line}}
rendering_error=Ocurrió un error al interpretar la página.
# Predefined zoom values
page_scale_width=Ancho de página
page_scale_fit=Ajustar a la página
page_scale_auto=Ampliación automática
page_scale_actual=Tamaño real
# Loading indicator messages
loading_error_indicator=Error
loading_error=Ocurrió un error al cargar el PDF.
invalid_file_error=Archivo PDF inválido o corrupto.
missing_file_error=Archivo PDF faltante.
# LOCALIZATION NOTE (text_annotation_type): This is used as a tooltip.
# "{{type}}" will be replaced with an annotation type from a list defined in
# the PDF spec (32000-1:2008 Table 169 – Annotation types).
# Some common types are e.g.: "Check", "Text", "Comment", "Note"
text_annotation_type=[Anotación {{type}}]
request_password=El archivo PDF está protegido por contraseña:
printing_not_supported=Advertencia: la impresión no está completamente soportada en este navegador.
web_fonts_disabled=Las tipografías web están deshabilitadas: no es posible utilizar tipografías PDF incrustadas.
# Copyright 2012 Mozilla Foundation
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# Main toolbar buttons (tooltips and alt text for images)
previous.title=Página anterior
previous_label=Anterior
next.title=Página siguiente
next_label=Siguiente
# LOCALIZATION NOTE (page_label, page_of):
# These strings are concatenated to form the "Page: X of Y" string.
# Do not translate "{{pageCount}}", it will be substituted with a number
# representing the total number of pages.
page_label=Página:
page_of=de {{pageCount}}
zoom_out.title=Reducir
zoom_out_label=Reducir
zoom_in.title=Ampliar
zoom_in_label=Ampliar
zoom.title=Ampliación
print.title=Imprimir
print_label=Imprimir
fullscreen.title=Pantalla completa
fullscreen_label=Pantalla completa
open_file.title=Abrir archivo
open_file_label=Abrir
download.title=Descargar
download_label=Descargar
bookmark.title=Vista actual (copie o abra en una ventana nueva)
bookmark_label=Vista actual
# Tooltips and alt text for side panel toolbar buttons
# (the _label strings are alt text for the buttons, the .title strings are
# tooltips)
toggle_slider.title=Alternar deslizador
toggle_slider_label=Alternar deslizador
outline.title=Mostrar esquema del documento
outline_label=Esquema del documento
thumbs.title=Mostrar miniaturas
thumbs_label=Miniaturas
findbar.title=Buscar en el documento
findbar_label=Buscar
# Document outline messages
no_outline=No hay un esquema disponible
# Thumbnails panel item (tooltip and alt text for images)
# LOCALIZATION NOTE (thumb_page_title): "{{page}}" will be replaced by the page
# number.
thumb_page_title=Página {{page}}
# LOCALIZATION NOTE (thumb_page_canvas): "{{page}}" will be replaced by the page
# number.
thumb_page_canvas=Miniatura de la página {{page}}
# Find panel button title and messages
find=Buscar
find_terms_not_found=(No encontrado)
# Error panel labels
error_more_info=Más información
error_less_info=Menos información
error_close=Cerrar
# LOCALIZATION NOTE (error_build): "{{build}}" will be replaced by the PDF.JS
# build ID.
error_build=Compilación de PDF.JS: {{build}}
# LOCALIZATION NOTE (error_message): "{{message}}" will be replaced by an
# english string describing the error.
error_message=Mensaje: {{message}}
# LOCALIZATION NOTE (error_stack): "{{stack}}" will be replaced with a stack
# trace.
error_stack=Pila: {{stack}}
# LOCALIZATION NOTE (error_file): "{{file}}" will be replaced with a filename
error_file=Archivo: {{file}}
# LOCALIZATION NOTE (error_line): "{{line}}" will be replaced with a line number
error_line=Línea: {{line}}
rendering_error=Ocurrió un error mientras se renderizaba la página.
# Predefined zoom values
page_scale_width=Anchura de página
page_scale_fit=Ajustar a la página
page_scale_auto=Ampliación automática
page_scale_actual=Tamaño real
# Loading indicator messages
loading_error_indicator=Error
loading_error=Ocurrió un error mientras se cargaba el PDF.
# LOCALIZATION NOTE (text_annotation_type): This is used as a tooltip.
# "{{type}}" will be replaced with an annotation type from a list defined in
# the PDF spec (32000-1:2008 Table 169 – Annotation types).
# Some common types are e.g.: "Check", "Text", "Comment", "Note"
text_annotation_type=[Anotación {{type}}]
request_password=El PDF está protegido con una contraseña:
printing_not_supported=Aviso: La impresión no es compatible totalmente con este navegador.
# Copyright 2012 Mozilla Foundation
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# Main toolbar buttons (tooltips and alt text for images)
previous.title=Edellinen sivu
previous_label=Edellinen
next.title=Seuraava sivu
next_label=Seuraava
# LOCALIZATION NOTE (page_label, page_of):
# These strings are concatenated to form the "Page: X of Y" string.
# Do not translate "{{pageCount}}", it will be substituted with a number
# representing the total number of pages.
page_label=Sivu:
page_of=/ {{pageCount}}
zoom_out.title=Suurenna
zoom_out_label=Suurenna
zoom_in.title=Pienennä
zoom_in_label=Pienennä
zoom.title=Sivun suurennus
print.title=Tulosta
print_label=Tulosta
fullscreen.title=Kokoruututila
fullscreen_label=Kokoruututila
open_file.title=Avaa tiedosto
open_file_label=Avaa
download.title=Lataa
download_label=Lataa
bookmark.title=Nykyinen näkymä (kopioi tai avaa uuteen ikkunaan)
bookmark_label=Nykyinen näkymä
# Tooltips and alt text for side panel toolbar buttons
# (the _label strings are alt text for the buttons, the .title strings are
# tooltips)
toggle_slider.title=Vaihda vieritysnäkymä
toggle_slider_label=Vaihda vieritysnäkymä
outline.title=Näytä asiakirjan jäsennys
outline_label=Asiakirjan jäsennys
thumbs.title=Näytä esikatselukuvat
thumbs_label=Esikatselukuvat
findbar.title=Etsi asiakirjasta
findbar_label=Etsi
# Document outline messages
no_outline=Jäsennystä ei ole tarjolla
# Thumbnails panel item (tooltip and alt text for images)
# LOCALIZATION NOTE (thumb_page_title): "{{page}}" will be replaced by the page
# number.
thumb_page_title=Sivu {{page}}
# LOCALIZATION NOTE (thumb_page_canvas): "{{page}}" will be replaced by the page
# number.
thumb_page_canvas=Sivun {{page}} esikatselukuva
# Find panel button title and messages
find=Etsi
find_terms_not_found=(Ei löytynyt)
# Error panel labels
error_more_info=Enemmän tietoa
error_less_info=Vähemmän tietoa
error_close=Sulje
# LOCALIZATION NOTE (error_build): "{{build}}" will be replaced by the PDF.JS
# build ID.
error_build=PDF.JS rakennus: {{build}}
# LOCALIZATION NOTE (error_message): "{{message}}" will be replaced by an
# english string describing the error.
error_message=Viesti: {{message}}
# LOCALIZATION NOTE (error_stack): "{{stack}}" will be replaced with a stack
# trace.
error_stack=Kutsupino: {{stack}}
# LOCALIZATION NOTE (error_file): "{{file}}" will be replaced with a filename
error_file=Tiedosto: {{file}}
# LOCALIZATION NOTE (error_line): "{{line}}" will be replaced with a line number
error_line=Rivi: {{line}}
rendering_error=Virhe on tapahtunut sivua mallintaessa.
# Predefined zoom values
page_scale_width=Sivun leveys
page_scale_fit=Sivun sovitus
page_scale_auto=Automaatinen sivun suurennus
page_scale_actual=Todellinen koko
# Loading indicator messages
loading_error_indicator=Virhe
loading_error=Virhe on tapahtunut PDF:ää ladattaessa.
# LOCALIZATION NOTE (text_annotation_type): This is used as a tooltip.
# "{{type}}" will be replaced with an annotation type from a list defined in
# the PDF spec (32000-1:2008 Table 169 – Annotation types).
# Some common types are e.g.: "Check", "Text", "Comment", "Note"
text_annotation_type=[{{type}} Selite]
request_password=PDF on salasanasuojattu:
printing_not_supported=Varoitus: Tämä selain ei täysin tue tulostusta.
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
previous.title=Page précédente
previous_label=Précédent
next.title=Page suivante
next_label=Suivant
page_label=Page :
page_of=sur {{pageCount}}
zoom_out.title=Zoom arrière
zoom_out_label=Zoom arrière
zoom_in.title=Zoom avant
zoom_in_label=Zoom avant
zoom.title=Zoom
print.title=Imprimer
print_label=Imprimer
presentation_mode.title=Basculer en mode présentation
presentation_mode_label=Mode présentation
open_file.title=Ouvrir le fichier
open_file_label=Ouvrir
download.title=Télécharger
download_label=Télécharger
bookmark.title=Affichage courant (copier ou ouvrir dans une nouvelle fenêtre)
bookmark_label=Affichage actuel
toggle_slider.title=Afficher/masquer le panneau latéral
toggle_slider_label=Afficher/masquer le panneau latéral
outline.title=Afficher les signets
outline_label=Signets du document
thumbs.title=Afficher les vignettes
thumbs_label=Vignettes
findbar.title=Rechercher dans le document
findbar_label=Rechercher
no_outline=Aucun signet disponible
thumb_page_title=Page {{page}}
thumb_page_canvas=Vignette de la page {{page}}
first_page.label=Aller à la première page
last_page.label=Aller à la dernière page
page_rotate_cw.label=Rotation horaire
page_rotate_ccw.label=Rotation anti-horaire
# Find panel button title and messages
find_label=Rechercher :
find_previous.title=Trouver l'occurrence précédente de la phrase
find_previous_label=Précédent
find_next.title=Trouver la prochaine occurrence de la phrase
find_next_label=Suivant
find_highlight=Tout surligner
find_match_case_label=Respecter la casse
find_wrapped_to_bottom=Bas de la page atteint, poursuite depuis la fin
find_wrapped_to_top=Bas de la page atteint, poursuite au début
find_not_found=Phrase introuvable
error_more_info=Plus d'informations
error_less_info=Moins d'informations
error_close=Fermer
error_build=Version de PDF.JS : {{build}}
error_message=Message : {{message}}
error_stack=Pile : {{stack}}
error_file=Fichier : {{file}}
error_line=Ligne : {{line}}
rendering_error=Une erreur s'est produite lors de l'affichage de la page.
page_scale_width=Pleine largeur
page_scale_fit=Page entière
page_scale_auto=Zoom automatique
page_scale_actual=Taille réelle
loading_error_indicator=Erreur
loading_error=Une erreur s'est produite lors du chargement du fichier PDF.
text_annotation_type=[Annotation {{type}}]
request_password=Le PDF est protégé par un mot de passe :
printing_not_supported=Attention : l'impression n'est pas totalement prise en charge par ce navigateur.
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